0% fanden dieses Dokument nützlich (0 Abstimmungen)
19 Ansichten

Java17

Hochgeladen von

Driton Jasiqi
Copyright
© © All Rights Reserved
Wir nehmen die Rechte an Inhalten ernst. Wenn Sie vermuten, dass dies Ihr Inhalt ist, beanspruchen Sie ihn hier.
Verfügbare Formate
Als PDF, TXT herunterladen oder online auf Scribd lesen
0% fanden dieses Dokument nützlich (0 Abstimmungen)
19 Ansichten

Java17

Hochgeladen von

Driton Jasiqi
Copyright
© © All Rights Reserved
Wir nehmen die Rechte an Inhalten ernst. Wenn Sie vermuten, dass dies Ihr Inhalt ist, beanspruchen Sie ihn hier.
Verfügbare Formate
Als PDF, TXT herunterladen oder online auf Scribd lesen
Sie sind auf Seite 1/ 971

ZIMK - Zentrum für

Informations-, Medien- und


Kommunikationstechnologie

Bernhard Baltes-Götz & Johannes Götz

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/

2022 (Rev. 220607)


Herausgeber: Zentrum für Informations-, Medien- und Kommunikationstechnologie (ZIMK)
an der Universität Trier
Universitätsring 15
D-54286 Trier
WWW: zimk.uni-trier.de
E-Mail: [email protected]
Autoren: Bernhard Baltes-Götz & Johannes Götz
E-Mail: [email protected]
Copyright © 2022; ZIMK
Vorwort
Dieses Manuskript basiert auf der Begleitlektüre zum Java-Einführungskurs, den das Zentrum für
Informations-, Medien- und Kommunikationstechnologie (ZIMK) an der Universität Trier im Win-
tersemester 2021/2022 angeboten hat, ist aber auch für das Selbststudium geeignet.

Inhalte und Lernziele


Die von der Firma Sun Microsystems (mittlerweile von der Firma Oracle übernommen) entwickelte
und 1995 veröffentlichte Programmiersprache Java hat sich als universelle, für vielfältige Zwecke
einsetzbare Sprache etabliert und kann insbesondere als attraktivste Lösung für die plattformunab-
hängige Entwicklung gelten. Java gehört zur ersten Liga der objektorientierten Programmierspra-
chen, und das objektorientierte Paradigma der Software-Entwicklung hat sich praktisch in der ge-
samten Branche als Standard etabliert.
Die Entscheidung der Firma Sun, Java beginnend mit der Version 6 als Open Source unter die GPL
(General Public License) zu stellen, ist in der Entwicklerszene positiv aufgenommen worden und
trägt zum anhaltenden Erfolg der Programmiersprache bei.
Allerdings steht Java nicht ohne Konkurrenz da. Nach dem fehlgeschlagenen Versuch, Java unter
der Bezeichnung J++ als Windows-Programmiersprache zu etablieren, hat die Firma Microsoft
mittlerweile mit der Programmiersprache C# für die .NET-Plattform ein ebenbürtiges Gegenstück
geschaffen (siehe z. B. Baltes-Götz 2021). Beide Konkurrenten inspirieren sich gegenseitig und
treiben so den Fortschritt voran. An diesem Fortschritt sind aber noch viele andere Programmier-
sprachen beteiligt. Obwohl immer wieder neue Sprachen auf den Markt drängen, sind die härtesten
Java-Konkurrenten ebenso alt oder sogar noch älter (C/C++ und Python).
Außerdem sind mittlerweile neben Java etliche weitere Sprachen zur Entwicklung von Programmen
für die Java-Laufzeitumgebung entstanden (z. B. Clojure, Groovy, JRuby, Jython, Kotlin, Scala).
Sie bieten dieselbe Plattformunabhängigkeit wie Java und können teilweise alternative Program-
miertechniken wie das funktionale Programmieren (früher) unterstützen, weil sie nicht zur Ab-
wärtskompatibilität verpflichtet sind. Diese Vielfalt (vergleichbar mit der Wahlfreiheit von Pro-
grammiersprachen für die .NET - Plattform) ist grundsätzlich zu begrüßen. Allerdings ist Java für
allgemeine Einsatzzwecke nicht zuletzt wegen der großen Verbreitung und Unterstützung weiterhin
zu bevorzugen. Nachhaltig relevante Programmiertechniken sind früher oder später auch in Java
verfügbar. So ist z. B. das funktionale Programmieren seit der Version 8 auch in Java möglich.
Das Manuskript beschränkt sich auf die Java Standard Edition (JSE) zur Entwicklung von Anwen-
dersoftware für Arbeitsplatzrechner, auf die viele weltweit populäre Softwarepakete setzen (z. B.
IBM SPSS Statistics, Matlab). Daneben gibt es sehr erfolgreiche Java-Editionen bzw. - Frameworks
für unternehmensweite oder serverorientierte Lösungen. Neben der Java Enterprise Edition (JEE),
die jüngst von der Firma Oracle an die Open Source Community (vertreten durch die Eclipse Foun-
dation) übergeben wurde, ist hier vor allem das Spring-Framework zu erwähnen. Eher auf dem
Rückzug ist die Java Micro Edition (JME) für Kommunikationsgeräte mit beschränkter Leistung.
Moderne Smartphones und Tablets zählen nicht mehr zu den Geräten mit beschränkter Leistung.
Sofern diese Geräte das Betriebssystem Android benutzen, kommt auch hier zur Software-
Entwicklung sehr oft die Programmiersprache Java zum Einsatz (siehe z. B. Baltes-Götz 2018). Im
Sommersemester 2022 bietet das ZIMK einen Kurs zur Android-Programmierung mit Java an.
iv Vorwort

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.

Voraussetzungen bei den Teilnehmenden1


• Programmierkenntnisse
Programmierkenntnisse werden nicht vorausgesetzt. Leser mit Programmiererfahrung wer-
den sich bei den ersten Kapiteln eventuell etwas langweilen.
• EDV-Plattform
Im Manuskript wird zur Demonstration ein PC unter Microsoft Windows 10 verwendet. Al-
lerdings sind alle verwendeten Programme und Bibliotheken auch für Linux und macOS
verfügbar.

Software zum Üben


Für die unverzichtbaren Übungen verwenden wir das Java SE Development Kit in den Versionen 8
und 17 sowie die Entwicklungsumgebung IntelliJ IDEA der Firma JetBrains in der Community Edi-
tion 2021.x. Die genannte Software ist kostenlos für alle signifikanten Plattformen (z. B. Linux,
macOS, Windows) im Internet verfügbar. Nähere Hinweise zum Bezug, zur Installation und zur
Verwendung folgen im Manuskript.

Aktuelles Manuskript und Dateien zum Kurs


Die aktuelle Version dieses Manuskripts sowie IntelliJ-Projekte mit den Beispielprogrammen bzw.
mit Lösungsvorschlägen zu den Übungsaufgaben sind auf dem Webserver der Universität Trier hier
zu finden:
https://www.uni-trier.de/index.php?id=22787
Leider blieb zu wenig Zeit für eine sorgfältige Kontrolle des Textes, sodass einige Fehler und Män-
gel verblieben sein dürften. Entsprechende Hinweise an die Mail-Adresse
[email protected]
werden dankbar entgegengenommen.2

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

1.2 Java-Programme ausführen 13


1.2.1 Java-Laufzeitumgebung installieren 13
1.2.2 Konsolenprogramme ausführen 17
1.2.3 Ausblick auf Anwendungen mit grafischer Bedienoberfläche 19
1.2.4 Ausführung auf einer beliebigen unterstützten Plattform 22

1.3 Architektur und Eigenschaften von Java-Software 22


1.3.1 Herkunft und Bedeutung der Programmiersprache Java 22
1.3.2 Quellcode, Bytecode und Maschinencode 23
1.3.3 Standardklassenbibliothek 26
1.3.4 Java-Editionen für verschiede Einsatzszenarien 27
1.3.5 Update- und Lizenzpolitik der Firma Oracle 29
1.3.6 Eigenschaften von Java-Software 29
1.3.6.1 Objektorientierung mit funktionalen Erweiterungen 29
1.3.6.2 Portabilität 30
1.3.6.3 Sicherheit 30
1.3.6.4 Robustheit 31
1.3.6.5 Einfachheit 32
1.3.6.6 Multithreading 32
1.3.6.7 Performanz 33
1.3.6.8 Beschränkungen 33

1.4 Übungsaufgaben zum Kapitel 1 33

2 WERKZEUGE ZUM ENTWICKELN VON JAVA-PROGRAMMEN 35


2.1 Aktuelles OpenJDK installieren 35
2.1.1 OpenJDK 17 der Firma Oracle 36
2.1.2 OpenJDK-Distributionen mit langfristiger Update-Versorgung 37

2.2 Java-Entwicklung mit dem JDK und einem Texteditor 37


2.2.1 Editieren 37
2.2.2 Übersetzen 40
2.2.3 Ausführen 42
2.2.4 Suchpfad für class-Dateien 43
2.2.5 Programmfehler beheben 45

2.3 IntelliJ IDEA Community installieren 46

2.4 Java-Entwicklung mit IntelliJ IDEA 50


2.4.1 Erster Start 50
2.4.2 Projekt anlegen 52
vi Inhaltsverzeichnis

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

2.5 OpenJFX und Scene Builder installieren 71

2.6 Übungsaufgaben zum Kapitel 2 74

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

3.2 Ausgabe bei Konsolenanwendungen 91


3.2.1 Ausgabe einer (zusammengesetzten) Zeichenfolge 91
3.2.2 Formatierte Ausgabe 92

3.3 Variablen und Datentypen 94


3.3.1 Strenge Compiler-Überwachung bei Java-Variablen 95
3.3.2 Variablennamen 97
3.3.3 Primitive Datentypen und Referenztypen 98
3.3.4 Klassifikation der Variablen nach Zuordnung 100
3.3.5 Eigenschaften einer Variablen 101
3.3.6 Primitive Datentypen in Java 102
3.3.7 Darstellung von Gleitkommazahlen im Arbeitsspeicher 104
3.3.7.1 Binäre Gleitkommadarstellung 104
3.3.7.2 Dezimale Gleitkommadarstellung 106
3.3.8 Variablendeklaration, Initialisierung und Wertzuweisung 108
3.3.9 Blöcke und Sichtbarkeitsbereiche für lokale Variablen 111
3.3.10 Finalisierte lokale Variablen 113
3.3.11 Literale 114
3.3.11.1 Ganzzahlliterale 114
3.3.11.2 Gleitkommaliterale 116
3.3.11.3 boolean-Literale 117
3.3.11.4 char-Literale 117
3.3.11.5 Zeichenfolgenliterale 118
3.3.11.6 Referenzliteral null 119
Inhaltsverzeichnis vii

3.4 Eingabe bei Konsolenprogrammen 120


3.4.1 Die Klassen Scanner und Simput 120
3.4.2 Eine globale Bibliothek mit der Klasse Simput in IntelliJ einrichten 123

3.5 Operatoren und Ausdrücke 126


3.5.1 Arithmetische Operatoren 128
3.5.2 Methodenaufrufe 130
3.5.3 Vergleichsoperatoren 131
3.5.4 Identitätsprüfung bei Gleitkommawerten 133
3.5.5 Logische Operatoren 136
3.5.6 Bitorientierte Operatoren 138
3.5.7 Typumwandlung (Casting) bei primitiven Datentypen 139
3.5.7.1 Automatische erweiternde Typanpassung 139
3.5.7.2 Explizite Typumwandlung 140
3.5.8 Zuweisungsoperatoren 141
3.5.9 Konditionaloperator 144
3.5.10 Auswertungsreihenfolge 145
3.5.10.1 Regeln 145
3.5.10.2 Operatorentabelle 148

3.6 Über- und Unterlauf bei numerischen Variablen 149


3.6.1 Überlauf bei Ganzzahltypen 150
3.6.2 Unendliche und undefinierte Werte bei den Typen float und double 152
3.6.3 Unterlauf bei den Gleitkommatypen 154
3.6.4 Modifikator strictfp 155

3.7 Anweisungen (zur Ablaufsteuerung) 156


3.7.1 Überblick 156
3.7.2 Bedingte Anweisung und Fallunterscheidung 157
3.7.2.1 if-Anweisung 157
3.7.2.2 if-else - Anweisung 158
3.7.2.3 switch-Anweisung 163
3.7.2.4 switch-Ausdruck (ab Java 14) 169
3.7.2.5 Mustervergleich (Vorschau in Java 17) 171
3.7.3 Wiederholungsanweisung 175
3.7.3.1 Zählergesteuerte Schleife (for) 177
3.7.3.2 Iterieren über die Elemente von Arrays oder Kollektionen 178
3.7.3.3 Bedingungsabhängige Schleifen 179
3.7.3.4 Endlosschleifen 181
3.7.3.5 Schleifen(durchgänge) vorzeitig beenden 182

3.8 Entspannungs- und Motivationseinschub: GUI-Standarddialoge 184

3.9 Übungsaufgaben zum Kapitel 3 188


Abschnitt 3.1 (Einstieg) 188
Abschnitt 3.2 (Ausgabe bei Konsolenanwendungen) 188
Abschnitt 3.3 (Variablen und Datentypen) 189
Abschnitt 3.4 (Eingabe bei Konsolen) 190
Abschnitt 3.5 (Operatoren und Ausdrücke) 190
Abschnitt 3.6 (Über- und Unterlauf bei numerischen Variablen) 191
Abschnitt 3.7 (Anweisungen (zur Ablaufsteuerung)) 192

4 KLASSEN UND OBJEKTE 195


4.1 Überblick, historische Wurzeln, Beispiel 196
4.1.1 Einige Kernideen und Vorzüge der OOP 196
4.1.1.1 Datenkapselung und Modularisierung 196
4.1.1.2 Vererbung 198
4.1.1.3 Polymorphie 200
4.1.1.4 Realitätsnahe Modellierung 201
4.1.2 Strukturierte Programmierung und OOP 201
4.1.3 Auf-Bruch zu echter Klasse 202
viii Inhaltsverzeichnis

4.2 Instanzvariablen (Felder) 206


4.2.1 Sichtbarkeitsbereich, Existenz und Ablage im Hauptspeicher 206
4.2.2 Deklaration mit Modifikatoren für den Zugriffsschutz und für andere Zwecke 207
4.2.3 Automatische Initialisierung auf den Voreinstellungswert 210
4.2.4 Verwendung in klasseneigenen und fremden Methoden 210
4.2.5 Finalisierte Instanzvariablen 212

4.3 Instanzmethoden 213


4.3.1 Methodendefinition 214
4.3.1.1 Modifikatoren 215
4.3.1.2 Rückgabewert und return-Anweisung 216
4.3.1.3 Namen 217
4.3.1.4 Formalparameter 217
4.3.1.5 Methodenrumpf 222
4.3.2 Methodenaufruf und Aktualparameter 222
4.3.3 Debug-Einsichten zu (verschachtelten) Methodenaufrufen 225
4.3.4 Methoden überladen 230

4.4 Objekte 232


4.4.1 Referenzvariablen deklarieren 232
4.4.2 Objekte erzeugen 233
4.4.3 Konstruktoren 235
4.4.4 Instanzinitialisierer 238
4.4.5 Objekte aus der Fabrik 239
4.4.6 Objektreferenzen verwenden 239
4.4.6.1 Rückgabe mit Referenztyp 240
4.4.6.2 this als Referenz auf das aktuelle Objekt 240
4.4.7 Abräumen überflüssiger Objekte durch den Garbage Collector 240
4.4.8 finalize() und Cleaner 242

4.5 Klassenvariablen und -methoden 244


4.5.1 Klassenvariablen 244
4.5.2 Wiederholung zur Kategorisierung von Variablen 246
4.5.3 Klassenmethoden 247
4.5.4 Statische Initialisierer 248

4.6 Rekursive Methoden 250

4.7 Komposition 252

4.8 Mitgliedsklassen und lokale Klassen 254


4.8.1 Mitgliedsklassen 255
4.8.1.1 Innere Klassen 255
4.8.1.2 Statische Mitgliedsklassen 258
4.8.2 Lokale Klassen 259

4.9 Bruchrechnungsprogramm mit JavaFX-GUI 261


4.9.1 JavaFX-Projekt mit dem OpenJDK 8 anlegen 261
4.9.2 Bedienoberfläche bzw. FXML-Datei mit dem Scene Builder gestalten 265
4.9.3 Klasse Bruch einbinden 271
4.9.4 Controller-Klasse vervollständigen 272
4.9.5 Programmstart 276

4.10 Übungsaufgaben zum Kapitel 4 277

5 WICHTIGE SPEZIELLE KLASSEN 283


5.1 Arrays 283
5.1.1 Array-Variablen deklarieren 284
5.1.2 Array-Objekte erzeugen 285
5.1.3 Kovariante Einbindung von Arrays in die Klassenhierarchie 286
5.1.4 Arrays verwenden 287
Inhaltsverzeichnis ix

5.1.5 Array-Kopien mit neuer Länge erstellen 288


5.1.6 Nützliche Methoden in der Klasse Arrays 288
5.1.7 Beispiel: Beurteilung des Java-Pseudozufallszahlengenerators 288
5.1.8 Initialisierungslisten 290
5.1.9 Objekte als Array-Elemente 291
5.1.10 Mehrdimensionale Arrays 291

5.2 Klassen für Zeichenfolgen 293


5.2.1 Die Klasse String für konstante Zeichenfolgen 294
5.2.1.1 Erzeugen von String-Objekten 294
5.2.1.2 String als WORM - Klasse 295
5.2.1.3 Interner String-Pool und Identitätsvergleich 295
5.2.1.4 Methoden für String-Objekte 297
5.2.1.5 Aufwand beim Inhalts- bzw. Referenzvergleich 301
5.2.2 Die Klassen StringBuilder und StringBuffer für veränderliche Zeichenfolgen 303
5.2.3 Mehrzeilige Textblöcke 305

5.3 Verpackungsklassen für primitive Datentypen 307


5.3.1 Wrapper-Objekte erstellen 307
5.3.2 Auto(un)boxing 308
5.3.3 Empfehlungen zur Verwendung von Verpackungsklassen 309
5.3.3.1 Identitätsoperator vermeiden 309
5.3.3.2 Wrapper-Objekte nicht für variable Werte verwenden 311
5.3.4 Konvertierungsmethoden 311
5.3.5 Konstanten für Grenz- bzw. Spezialwerte 312
5.3.6 Character-Methoden zur Zeichen-Klassifikation 313

5.4 Aufzählungstypen 313


5.4.1 Einfache Enumerationstypen 314
5.4.2 Erweiterte Enumerationstypen 316
5.4.3 Innere und lokale Aufzählungstypen 317

5.5 Records 319


5.5.1 Einfach Datenklassendefinition 319
5.5.2 Optionen und Einschränkungen bei der Definition von Record-Klassen 322
5.5.2.1 Vererbung 322
5.5.2.2 Expliziter Konstruktor 322
5.5.2.3 Überschreiben der automatisch erstellten Methoden 323
5.5.2.4 Zusätzliche Record-Member 323
5.5.2.5 Innere und lokale Record-Klassen 324

5.6 Übungsaufgaben zum Kapitel 5 325


Abschnitt 5.1 (Arrays) 325
Abschnitt 5.2 (Klassen für Zeichen) 327
Abschnitt 5.3 (Verpackungsklassen für primitive Datentypen) 329
Abschnitt 5.4 (Aufzählungstypen) 330
Abschnitt 5.5 (Records) 330

6 PAKETE UND MODULE 331


6.1 Pakete 333
6.1.1 Pakete erstellen 333
6.1.1.1 package-Deklaration und Paketordner 333
6.1.1.2 Standardpaket 335
6.1.1.3 Unterpakete 335
6.1.1.4 Paketunterstützung in IntelliJ 337
6.1.1.5 Regeln und Konventionen für Paketnamen 339
6.1.2 Pakete verwenden 341
6.1.2.1 Verfügbarkeit der class-Dateien (Klassenpfad) 341
6.1.2.2 Typen aus fremden Paketen ansprechen 343
6.1.2.3 Startklasse in einem benannten Paket 345
x Inhaltsverzeichnis

6.1.3 Traditionelle jar-Dateien (Java 8) 347


6.1.3.1 Eigenschaften von Java-Archivdateien 348
6.1.3.2 Archivdateien mit dem JDK-Werkzeug jar erstellen 349
6.1.3.3 Archivdateien verwenden 350
6.1.3.4 Ausführbare jar-Dateien 351
6.1.3.5 Archivunterstützung in IntelliJ 353
6.1.3.6 Ausführbare jar-Datei für ein Projekt mit OpenJFX 8 354

6.2 Module 358


6.2.1 Moduldeklarationsdatei module-info.java 360
6.2.1.1 Modulnamen 360
6.2.1.2 requires-Deklaration 360
6.2.1.3 exports-Deklaration 362
6.2.1.4 uses- und provides-Deklaration 363
6.2.1.5 opens-Deklaration 364
6.2.2 Quellcode-Organisation 364
6.2.3 Übersetzung in ein explodiertes Modul und den Moduldeskriptor 367
6.2.4 Modulpfad 368
6.2.5 Ausführen 369
6.2.6 Modulare jar-Dateien 370
6.2.7 Unterstützung für Java-Module in IntelliJ IDEA 373
6.2.7.1 Modul de.uni_trier.zimk.util 374
6.2.7.2 Modul de.uni_trier.zimk.matrain 377
6.2.7.3 Hauptmodul de.uni_trier.zimk.ba 379
6.2.8 Eigenständige Anwendungen mit maßgeschneiderter Laufzeitumgebung 382
6.2.9 Kompatibilität und Migration 383
6.2.9.1 Automatische Module 384
6.2.9.2 Das unbenannte Modul 384
6.2.9.3 Notlösung 385
6.2.9.4 Moduldeklaration zu vorhandenem Quellcode erstellen 385
6.2.10 Das modulare API der Java Standard Edition 386
6.2.11 Modul-Taxonomie 388

6.3 Zugriffsschutz 388


6.3.1 Sichtbarkeit von Top-Level - Typen 389
6.3.2 Sichtbarkeit von Typmitgliedern 390

6.4 Übungsaufgaben zum Kapitel 6 392

7 VERERBUNG UND POLYMORPHIE 393


7.1 Definition einer abgeleiteten Klasse 395

7.2 Der Zugriffsmodifikator protected 396

7.3 Basisklassenkonstruktoren und Initialisierungsmaßnahmen 397

7.4 Überschreiben und Überdecken 398


7.4.1 Überschreiben von Instanzmethoden 398
7.4.2 Überdecken von statischen Methoden 401
7.4.3 Finalisierte Methoden 402
7.4.4 Felder überdecken 403

7.5 Verwaltung von Objekten über Basisklassenreferenzen 404

7.6 Der instanceof-Operator 405

7.7 Polymorphie 406

7.8 Abstrakte Methoden und Klassen 407


Inhaltsverzeichnis xi

7.9 Das Liskovsche Substitutionsprinzip 409

7.10 Unerwünschte Abhängigkeiten durch Vererbung 410


7.10.1 Risiken für abgeleitete Klassen 410
7.10.2 Nachteile für potentielle Basisklassen 411

7.11 Erzwungene und optionale Einschränkungen beim Vererben 412


7.11.1 Keine Mehrfachvererbung 412
7.11.2 Finale Klassen 412
7.11.3 Versiegelte Klassen 413

7.12 Übungsaufgaben zum Kapitel 7 415

8 GENERISCHE KLASSEN UND METHODEN 417


8.1 Generische Klassen 417
8.1.1 Vorzüge und Verwendung generischer Klassen 417
8.1.1.1 Veraltete Technik mit Risiken und Umständlichkeiten 417
8.1.1.2 Generische Klassen bringen Typsicherheit und Bequemlichkeit 419
8.1.2 Technische Details und Komplikationen 420
8.1.2.1 Typlöschung und Rohtyp 420
8.1.2.2 Spezialisierungsbeziehungen bei parametrisierten Klassen und bei Arrays 422
8.1.2.3 Keine Array-Kreation mit einem nicht-reifizierbaren Elementtyp 424
8.1.2.4 Serienparameter mit einem parametrisierten Typ 425
8.1.3 Definition von generischen Klassen 427
8.1.3.1 Unbeschränkte Typformalparameter 427
8.1.3.2 Beschränkte Typformalparameter 432

8.2 Generische Methoden 435

8.3 Wildcard-Datentypen 438


8.3.1 Beschränkte Wildcard-Typen 439
8.3.1.1 Beschränkung nach oben 439
8.3.1.2 Beschränkung nach unten 440
8.3.2 Unbeschränkte Wildcard-Typen 441
8.3.3 Verwendungszwecke für Wildcard-Datentypen 441

8.4 Einschränkungen der Generizitätslösung in Java 442


8.4.1 Konkretisierung von Typformalparametern nur durch Referenztypen 442
8.4.2 Typlöschung und die Folgen 442
8.4.2.1 Keine Typparameter bei der Definition von statischen Mitgliedern 442
8.4.2.2 Member aus einer per Typparameter bestimmten Klasse sind verboten 443
8.4.2.3 Member aus einer per Typparameter konkretisierten generischen Klasse sind erlaubt 444

8.5 Übungsaufgaben zum Kapitel 8 445

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

9.2 Interfaces definieren 450


9.2.1 Kopf einer Schnittstellen-Definition 452
9.2.2 Vererbung bzw. Erweiterung bei Schnittstellen 452
9.2.3 Schnittstellen-Methoden 453
9.2.3.1 Abstrakte Instanzmethoden 453
9.2.3.2 Instanzmethoden mit default-Implementierung 453
9.2.3.3 Statische Methoden 456
9.2.3.4 Private Interface-Methoden 457
xii Inhaltsverzeichnis

9.2.4 Konstanten 458


9.2.5 Statische Mitgliedstypen 459
9.2.6 Lokale Schnittstellen 460
9.2.7 Zugriffsschutz bei Mitgliedern von Schnittstellen 460
9.2.8 Marker - Interfaces 460

9.3 Interfaces implementieren 460


9.3.1 Mehrere Schnittstellen implementieren 462
9.3.2 Geerbte Interface-Implementationen 463
9.3.3 Implementieren von Schnittstellen und Sichtbarkeit von Klassen 465

9.4 Interfaces als Referenzdatentypen verwenden 466

9.5 Versiegelte Interfaces 467

9.6 Annotationen 468


9.6.1 Definition 469
9.6.2 Zuweisung 471
9.6.3 Runtime-Annotationen per Reflexion auswerten 471
9.6.4 API-Annotationen 472
9.6.5 Annotationen bei Record-Klassen 475

9.7 Übungsaufgaben zum Kapitel 9 477

10 JAVA COLLECTIONS FRAMEWORK 479


10.1 Arrays versus Kollektionen 479

10.2 Zur Rolle von Schnittstellen beim JCF-Design 481

10.3 Das Interface Collection<E> 483


10.3.1 Basiskompetenzen einer Kollektion 483
10.3.2 Optionale Operationen 486
10.3.3 Anforderungen an den Elementtyp 486

10.4 Listen 487


10.4.1 Das Interface List<E> 488
10.4.2 Beispiel 490
10.4.3 Listenarchitekturen 492
10.4.3.1 Array als Hintergrundspeicher 492
10.4.3.2 Verkette Objekte 493
10.4.4 Leistungsunterschiede und Einsatzempfehlungen 494

10.5 Iteratoren 496

10.6 Mengen 498


10.6.1 Das Interface Set<E> 499
10.6.2 Leistungsvorteil bei der Existenzprüfung 501
10.6.3 Hashtabellen 501
10.6.4 Balancierte Binärbäume 503
10.6.5 Interfaces für geordnete Mengen 506

10.7 Abbildungen 510


10.7.1 Das Interface Map<K,V> 511
10.7.2 Die Klasse HashMap<K,V> 516
10.7.3 Interfaces für Abbildungen mit geordneten Schlüsseltypen 518
10.7.4 Die Klasse TreeMap<K,V> 520

10.8 Vergleich der Kollektionsarchitekturen 521

10.9 Warteschlangen 522


Inhaltsverzeichnis xiii

10.10 Nützliche Methoden in der Klasse Collections 523

10.11 Übungsaufgaben zum Kapitel 10 525

11 AUSNAHMEBEHANDLUNG 529
11.1 Prävention und Beispielprogramm 530

11.2 Unbehandelte Ausnahmen 532

11.3 Ausnahmen abfangen 533


11.3.1 Die try-catch-finally - Anweisung 533
11.3.1.1 Ausnahmebehandlung per catch-Block 534
11.3.1.2 Aufräumarbeiten im finally-Block 537
11.3.2 Programmablauf bei der Ausnahmebehandlung 539
11.3.3 Diagnostische Ausgaben 541

11.4 Ausnahmeobjekte im Vergleich zur Fehlerkommunikation per Rückgabe 543


11.4.1 Traditionelle Rückgabewerte 543
11.4.2 Rückgabetyp Optional<T> 546

11.5 Ausnahmen und Fehler 547


11.5.1 Error 548
11.5.2 Geprüfte und ungeprüfte Ausnahmen 551
11.5.2.1 Unterschiedliche Behandlung durch den Compiler 551
11.5.2.2 Eine schwierige Unterscheidung 553

11.6 Ausnahmen in einer eigenen Methode werfen und ankündigen 554


11.6.1 Ausnahmen auslösen (throw), ankündigen (throws) und dokumentieren 554
11.6.2 Compiler-Intelligenz beim erneuten Werfen von abgefangenen Ausnahmen 557

11.7 Ausnahmen bei der Parameter-Validierung 558

11.8 Vollständige Beschreibung der try-catch-finally - Ausführung 559

11.9 Ausnahmen definieren 561

11.10 Freigabe von Ressourcen 563


11.10.1 Traditionelle Lösung per finally-Block 563
11.10.2 Try With Resources 565

11.11 Übungsaufgaben zum Kapitel 11 566

12 FUNKTIONALES PROGRAMMIEREN 569


12.1 Lambda-Ausdrücke 569
12.1.1 Traditionelle und moderne Realisation von Funktionsobjekten 570
12.1.1.1 Funktionale Schnittstellen 570
12.1.1.2 Anonyme Klassen 572
12.1.1.3 Compiler-Kompetenz statt Boilerplate-Code 576
12.1.2 Definition von Lambda-Ausdrücken 578
12.1.2.1 Formalparameterliste 578
12.1.2.2 Rumpf 579
12.1.2.3 Definitionsumgebungen 580
12.1.3 Methoden- und Konstruktorreferenzen 582
12.1.3.1 Methodenreferenzen 582
12.1.3.2 Konstruktorreferenzen 584

12.2 Ströme 585


12.2.1 Elementare Begriffe und Beispiel 585
12.2.2 Externe versus interne Iteration 587
xiv Inhaltsverzeichnis

12.2.3 Eigenschaften von Strömen 589


12.2.3.1 Datentyp der Elemente 589
12.2.3.2 Sequentiell oder parallel 589
12.2.4 Erstellung von Stromobjekten 589
12.2.4.1 Stromobjekt aus einer Kollektion (stream, parallelStream) 589
12.2.4.2 Stromobjekt aus einem Array oder aus einer Serie von Werten (stream, of) 590
12.2.4.3 Stromobjekte mit einer Sequenz ganzer Zahlen (range, rangeClosed) 590
12.2.4.4 Unendliche Ströme (iterate, generate) 590
12.2.4.5 Sonstige Erstellungsmethoden 591
12.2.5 Stromoperationen 592
12.2.5.1 Intermediäre und terminale Stromoperationen 592
12.2.5.2 Faulheit ist nicht immer dumm 593
12.2.5.3 Intermediäre Operationen 594
12.2.5.4 Terminale Operationen 599

12.3 Empfehlungen für erfolgreiches funktionales Programmieren 608


12.3.1.1 Deklarieren statt Kommandieren 608
12.3.1.2 Veränderliche Variablen vermeiden 609
12.3.1.3 Seiteneffekte vermeiden 610
12.3.1.4 Ausdrücke bevorzugen gegenüber Anweisungen 610
12.3.1.5 Verwendung von Funktionen höherer Ordnung 610

12.4 Übungsaufgaben zum Kapitel 12 611

13 GUI-PROGRAMMIERUNG MIT JAVAFX 613


13.1 Einordnung 613
13.1.1 Vergleich von Konsolen- und GUI-Programmen 613
13.1.2 Desktop-GUI-Lösungen in Java 615

13.2 Einstieg in JavaFX 617


13.2.1 Beispiel Anwesenheitsliste 617
13.2.2 Lebenszyklus einer JavaFX-Anwendung 621
13.2.3 Bühne, Szene und Szenengraph 624

13.3 Anwendung mit All-In-One - Architektur 625

13.4 Anwendung mit Model-View-Controller - Architektur (MVC) 630


13.4.1 Das Model-View-Controller - Konzept 630
13.4.2 Projekt anlegen 631
13.4.3 Model 632
13.4.4 GUI-Gestaltung per Scene Builder 633
13.4.5 FXML 635
13.4.6 Controller 637
13.4.7 Anwendungsklasse 640

13.5 Properties mit Änderungssignalisierung und automatischer Synchronisation 642


13.5.1 Basiswissen über Properties 642
13.5.1.1 Traditionelle JavaBean-Eigenschaften 642
13.5.1.2 Property-Klassen von JavaFX 643
13.5.1.3 Vermeidung von überflüssigen Objektkreationen 646
13.5.2 Invalidierungs- und Veränderungmitteilungen 647
13.5.3 Automatische Synchronisation von Property-Objekten 648
13.5.3.1 Uni- und bidirektionale Synchronisation von Property-Objekten 649
13.5.3.2 Property-Objekt an ein Berechnungsergebnis binden 650
13.5.3.3 Beobachtbare Listen 654

13.6 Ereignisse 658


13.6.1 Ereignishierarchie 658
13.6.2 Ereignisverarbeitung 660
13.6.2.1 Top-Down - Route 660
13.6.2.2 Bottom-Up - Route 662
Inhaltsverzeichnis xv

13.6.3 Ereignis-Properties und Bequemlichkeitsmethoden 663

13.7 Layoutmanager 664


13.7.1 GridPane 665
13.7.1.1 Beispiel 665
13.7.1.2 GridPane-Eigenschaften zur Gestaltung von Abständen 665
13.7.1.3 Anzeigeeinstellungen für Kindelemente (layout constraints) 666
13.7.1.4 Dynamische Platzverteilung auf mehrere Spalten bzw. Zeilen 667
13.7.2 AnchorPane 668
13.7.3 HBox und VBox 669
13.7.4 BorderPane 670
13.7.5 FlowPane 672
13.7.6 StackPane 673

13.8 Elementare Steuerelemente 674


13.8.1 Label 674
13.8.2 Button 676
13.8.3 Einzeiliges Texteingabefeld 678
13.8.4 Umschalter 680
13.8.4.1 Kontrollkästchen 680
13.8.4.2 Radioschalter 682

13.9 Modulare JavaFX-Anwendung ausliefern 683

13.10 Übungsaufgaben zum Kapitel 13 685

14 EIN- UND AUSGABE ÜBER DATENSTRÖME 689


14.1 Grundlagen 689
14.1.1 Datenströme 689
14.1.2 Beispiel 690
14.1.3 Klassifikation der Stromverarbeitungsklassen 691
14.1.4 Aufbau und Verwendung der Transformationsklassen 692
14.1.5 Zum guten Schluss 694

14.2 Verwaltung von Dateien und Verzeichnissen 696


14.2.1 Dateisystemzugriffe über das NIO.2 - API 697
14.2.1.1 Repräsentation von Dateisystemeinträgen 697
14.2.1.2 Existenzprüfung 699
14.2.1.3 Verzeichnis anlegen 700
14.2.1.4 Datei explizit erstellen 700
14.2.1.5 Attribute von Dateisystemobjekten ermitteln 700
14.2.1.6 Zugriffsrechte für Dateien ermitteln 702
14.2.1.7 Attribute ändern 702
14.2.1.8 Über Verzeichniseinträge iterieren 703
14.2.1.9 Datei und Ordner kopieren 703
14.2.1.10 Umbenennen und Verschieben 704
14.2.1.11 Löschen 705
14.2.1.12 Informationen über Dateisysteme ermitteln 705
14.2.1.13 Weitere Optionen 706
14.2.2 Dateisystemzugriffe über die Klasse File aus dem Paket java.io 707
14.2.2.1 Verzeichnis anlegen 707
14.2.2.2 Dateien explizit erstellen 708
14.2.2.3 Informationen über Dateien und Ordner ermitteln 708
14.2.2.4 Atribute ändern 709
14.2.2.5 Verzeichnisinhalte auflisten 709
14.2.2.6 Umbenennen 710
14.2.2.7 Löschen 710
xvi Inhaltsverzeichnis

14.3 Klassen zur Verarbeitung von Byte-Strömen 711


14.3.1 Die OutputStream-Hierarchie 711
14.3.1.1 Überblick 711
14.3.1.2 FileOutputStream 712
14.3.1.3 OutputStream mit Dateianschluss per NIO.2 - API 714
14.3.1.4 DataOutputStream 715
14.3.1.5 BufferedOutputStream 716
14.3.1.6 PrintStream 718
14.3.2 Die InputStream-Hierarchie 720
14.3.2.1 Überblick 720
14.3.2.2 FileInputStream 722
14.3.2.3 InputStream mit Dateianschluss per NIO.2 - API 723
14.3.2.4 DataInputStream 723

14.4 Klassen zur Verarbeitung von Zeichenströmen 724


14.4.1 Die Writer-Hierarchie 724
14.4.1.1 Überblick 724
14.4.1.2 Brückenklasse OutputStreamWriter 725
14.4.1.3 FileWriter 728
14.4.1.4 BufferedWriter 729
14.4.1.5 PrintWriter 731
14.4.1.6 BufferedWriter mit Dateianschluss per NIO.2 - API 733
14.4.2 Die Reader-Hierarchie 734
14.4.2.1 Überblick 734
14.4.2.2 Brückenklasse InputStreamReader 735
14.4.2.3 FileReader und BufferedReader 736
14.4.2.4 BufferedReader mit Dateianschluss per NIO.2 - API 737

14.5 Zahlen und Zeichenfolgen aus einer Textdatei lesen 738

14.6 Objektserialisierung 742


14.6.1 Objektserialisierung und Sicherheit 743
14.6.2 Beispiel für eine serialisierbare Klasse 743
14.6.3 Versionskontrolle und Kompatibilitätsprobleme 744
14.6.4 Objekte in eine Datei schreiben und von dort lesen 745
14.6.5 Von der Serialisierung ausgeschlossene Felder 747
14.6.6 Mehr Kontrolle und Eigenverantwortung 748
14.6.7 Serialisierung und Vererbung 749
14.6.8 Serialisierung bei Record-Klassen 750
14.6.9 Bewertung der Objektserialisierung und mögliche Alternativen 751

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

14.8 Empfehlungen zur Ein- und Ausgabe 755


14.8.1 Ausgabe in eine Textdatei 756
14.8.2 Textzeilen einlesen 757
14.8.3 Zahlen und andere Tokens aus einer Textdatei lesen 758
14.8.4 Eingabe von der Konsole 759
14.8.5 Werte mit primitiven Datentypen in eine Binärdatei schreiben 759
14.8.6 Werte mit primitiven Datentypen aus einer Binärdatei lesen 760
14.8.7 Binäre Objekt(de)serialisierung 761

14.9 Übungsaufgaben zum Kapitel 14 762


Inhaltsverzeichnis xvii

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

15.2 Threads koordinieren 773


15.2.1 Fehlerhafte oder veraltete Daten 773
15.2.1.1 Fehlerhafte Daten aufgrund von nicht-atomaren Operationen 773
15.2.1.2 Veraltete Daten im lokalen Cache eines Threads 775
15.2.2 Per Monitor synchronisierte Code-Bereiche 775
15.2.2.1 Synchronisierte Methoden und Blöcke 776
15.2.2.2 Koordination per wait(), notify() und notifyAll() 778
15.2.3 Explizite Lock-Objekte 780
15.2.3.1 Interface Lock und Klasse ReentrantLock 780
15.2.3.2 Koordination per await(), signal() und signalAll() 783
15.2.4 Automatisierte Thread-Koordination für Produzenten-Konsumenten - Konstellationen 785
15.2.4.1 BlockingQueue<E> 785
15.2.4.2 PipedOutputStream und PipedInputStream 787
15.2.5 Klassen zur Thread-Synchronisation 789
15.2.5.1 Semaphore 789
15.2.5.2 CountDownLatch 792
15.2.5.3 CyclicBarrier 793
15.2.5.4 Phaser 795
15.2.6 Nicht-blockierende Koordination 797
15.2.6.1 Modifikator volatile 797
15.2.6.2 Atomare Variablen 798

15.3 Direkt gestartete Threads verwalten 800


15.3.1 Weck mich, wenn Du fertig bist (join) 800
15.3.2 Andere Threads unterbrechen, fortsetzen oder abbrechen 801
15.3.2.1 Unterbrechen und fortsetzen 801
15.3.2.2 Abbrechen 802
15.3.3 Thread-Lebensläufe 804
15.3.3.1 Scheduling und Prioritäten 804
15.3.3.2 Zustände von Threads 805
15.3.4 Deadlock 806

15.4 Aufgaben per Threadpool erledigen 807


15.4.1 ExecutorService 807
15.4.2 Die Schnittstellen Callable<T> und Future<T> 810
15.4.3 Threadpools mit Timer-Funktionalität 812

15.5 Datenparallelität mit Hilfe des Fork-Join - Frameworks 814


15.5.1 Direkte Verwendung des Fork-Join - Frameworks 814
15.5.2 Parallele Aggregatoperationen mit Strömen 818

15.6 Aufgabenparallelität mit Hilfe der Klasse CompletableFuture<T> 819


15.6.1 Asynchrone Verarbeitung einer einzelnen Aufgabe 820
15.6.1.1 Aufgaben ohne Rückgabewert 820
15.6.1.2 Aufgaben mit Rückgabewert 821
15.6.2 Folgeaufgaben 822
15.6.2.1 Unproduktiver Nachfolger zu einer Aufgabe ohne Rückgabewert 822
15.6.2.2 Produktiver Nachfolger zu einer Aufgabe mit Rückgabewert 825
15.6.2.3 Konsumierender Nachfolger zu einer Aufgabe mit Rückgabewert 827
15.6.3 Folgeaufgaben mit zwei Vorgängern 829
15.6.3.1 AND-Zusammenführung von zwei Aufgaben mit Rückgabe 829
15.6.3.2 OR-Zusammenführung von zwei Aufgaben mit Rückgabe 831
15.6.3.3 Zusammenführung von zwei Aufgaben ohne Rückgabe 832
xviii Inhaltsverzeichnis

15.6.4 Ausnahmebehandlung 833


15.6.4.1 Ausnahme diagnostizieren 833
15.6.4.2 Ausnahme behandeln und Verarbeitungskette fortsetzen 835
15.6.4.3 Ausnahme protokollieren 838
15.6.5 Auf eine Serie von Aufgaben warten 839
15.6.5.1 Auf die Beendigung aller Aufgaben warten 839
15.6.5.2 Auf die Beendigung eine beliebigen Aufgabe warten 841

15.7 Thread-sichere Kollektionen im Paket java.util.concurrent 842

15.8 Threads und JavaFX 845


15.8.1 JavaFX-Komponenten aus einem Hintergrund-Thread modifizieren 845
15.8.2 Das JavaFX-Multithreading - API 846
15.8.3 Die Klasse Task<V> 848

15.9 Reaktive Ströme (Flow-API) 851


15.9.1 Flow-Schnittstellen 851
15.9.1.1 Publisher<T> 852
15.9.1.2 Subscriber<T> 852
15.9.1.3 Subscription 853
15.9.1.4 Processor<T,R> 853
15.9.2 Zentrale Prinzipien der reaktiven Stromverarbeitung 854
15.9.3 Klasse SubmissionPublisher<T> 854
15.9.3.1 Versorgung eines einzelnen Subscribers per submit() 855
15.9.3.2 Auslieferungsmethode offer() als Alternative zur blockierenden Methode submit() 857

15.10 Sonstige Thread-Themen 860


15.10.1 Daemon-Threads 860
15.10.2 Thread-Gruppen 860

15.11 Übungsaufgaben zum Kapitel 15 860

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

16.2 Internet-Ressourcen per HTTP-Protokoll nutzen 871


16.2.1 Uniform Resource Locator 871
16.2.2 HTTP/1.1 - API 872
16.2.2.1 URL 872
16.2.2.2 URLConnection 876
16.2.2.3 HttpsURLConnection 878
16.2.3 Dynamisch erstellte Webinhalte anfordern 880
16.2.3.1 CGI-Software 880
16.2.3.2 GET 882
16.2.3.3 POST 883
16.2.4 HTTP/2 - API 885
16.2.4.1 Synchrone Anforderung einer statischen Webseite 886
16.2.4.2 Asynchrone Anforderung einer statischen Webseite 889
16.2.4.3 Synchrone POST-Anforderung 890
16.2.5 Nutzung der Browser-Komponente WebView in JavaFX-Anwendungen 892

16.3 IP-Adressen bzw. Host-Namen ermitteln 895


Inhaltsverzeichnis xix

16.4 Socket-Programmierung 896


16.4.1 TCP-Klient 897
16.4.1.1 Socket-Konstruktorüberladungen 898
16.4.1.2 Ein Datetime-Klient 898
16.4.1.3 Timeout setzen 899
16.4.1.4 Socket schließen 899
16.4.2 TCP-Server 900
16.4.2.1 Firewall-Ausnahme für einen TCP-Server unter Windows 10 900
16.4.2.2 Singlethreading-Server 900
16.4.2.3 Multithreading-Server 902

16.5 Übungsaufgaben zu Kapitel 16 905

ANHANG 907
A. Operatorentabelle 907

B. Lösungsvorschläge zu den Übungsaufgaben 908


Kapitel 1 (Einleitung) 908
Kapitel 2 (Werkzeuge zum Entwickeln von Java-Programmen) 908
Kapitel 3 (Elementare Sprachelemente) 909
Abschnitt 3.1 (Einstieg) 909
Abschnitt 3.2 (Ausgabe bei Konsolenanwendungen) 910
Abschnitt 3.3 (Variablen und Datentypen) 911
Abschnitt 3.4 (Eingabe bei Konsolen) 912
Abschnitt 3.5 (Operatoren und Ausdrücke) 913
Abschnitt 3.6 (Über- und Unterlauf bei numerischen Variablen) 914
Abschnitt 3.7 (Anweisungen (zur Ablaufsteuerung)) 914
Kapitel 4 (Klassen und Objekte) 916
Kapitel 5 (Wichtige spezielle Klassen) 918
Abschnitt 5.1 (Arrays) 918
Abschnitt 5.2 (Klassen für Zeichen) 919
Abschnitt 5.3 (Verpackungsklassen für primitive Datentypen) 919
Abschnitt 5.4 (Aufzählungstypen) 920
Abschnitt 5.5 (Records) 920
Kapitel 6 (Pakete und Module) 920
Kapitel 7 (Vererbung und Polymorphie) 921
Kapitel 8 (Generische Klassen und Methoden) 922
Kapitel 9 (Interfaces) 922
Kapitel 10 (Java Collections Framework) 923
Kapitel 11 (Ausnahmebehandlung) 924
Kapitel 12 (Funktionales Programmieren) 925
Kapitel 13 (GUI-Programmierung mit JavaFX) 926
Kapitel 14 (Ein- und Ausgabe über Datenströme) 926
Kapitel 15 (Multithreading) 927
Abschnitt 16 (Netzwerkprogrammierung) 928

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.

1.1 Beispiel für die objektorientierte Software-Entwicklung mit Java


In diesem Abschnitt soll eine Vorstellung davon vermittelt werden, was ein Computerprogramm (in
Java) ist. Dabei kommen einige Grundbegriffe der Informatik zur Sprache, wobei wir uns aber nicht
unnötig lange von der Praxis fernhalten wollen.
Ein Computerprogramm besteht im Wesentlichen (von Medien und anderen Ressourcen einmal
abgesehen) aus einer Menge von wohlgeformten und wohlgeordneten Definitionen und Anweisun-
gen zur Bewältigung bestimmter Aufgaben. Ein Programm muss ...
• den betroffenen Anwendungsbereich modellieren
Beispiel: In einem Programm zur Verwaltung einer Spedition sind z. B. Kunden, Aufträge,
Mitarbeiter, Fahrzeuge, Einsatzfahrten, (Ent-)ladestationen etc. und
kommunikative Prozesse (Nachrichten zwischen beteiligten Akteuren) zu reprä-
sentieren.
• Algorithmen realisieren, die in endlich vielen Schritten und unter Verwendung von endlich
vielen Betriebsmitteln (z. B. Speicher, CPU-Leistung) bestimmte Ausgangszustände in ak-
zeptable Zielzustände überführen.
Beispiel: Im Speditionsprogramm muss u. a. für jede Tour zu den meist mehreren (Ent-)la-
destationen eine optimale Route ermittelt werden (hinsichtlich Kraftstoffver-
brauch, Fahrtzeit, Mautkosten etc.).
Wir wollen präzisere und komplettere Definitionen zum komplexen Begriff eines Computerpro-
gramms den Informatik-Lehrbüchern überlassen (siehe z. B. Goll & Heinisch 2016) und stattdessen
ein Beispiel im Detail betrachten, um einen Einstieg in die Materie zu finden.
Bei der Suche nach einem geeigneten Java-Einstiegsbeispiel tritt ein Dilemma auf:
• Einfache Beispiele sind angenehm, aber für das Programmieren mit Java nicht besonders re-
präsentativ. Z. B. ist von der Objektorientierung außer einem gewissen Formalismus nichts
vorhanden.
• Repräsentative Java-Programme eignen sich in der Regel wegen ihrer Länge und Komplexi-
tät (aus der Sicht eines Anfängers) nicht für den Einstieg. Beispielsweise können wir das
eben zur Illustration einer realen Aufgabenstellung verwendete, aber potentiell sehr aufwän-
dige Speditionsverwaltungsprogramm jetzt nicht vorstellen.
Wir analysieren ein Beispielprogramm, das trotz angestrebter Einfachheit nicht auf objektorientier-
tes Programmieren (OOP) verzichtet. Seine Aufgabe besteht darin, elementare Operationen mit
Brüchen auszuführen (z. B. Kürzen, Addieren), womit es etwa einem Schüler beim Anfertigen der
Hausaufgaben (zur Kontrolle der eigenen Lösungen) nützlich sein kann. Das Beispiel wird in suk-
zessive ausgebauter Form im Kurs noch oft verwendet.

1.1.1 Objektorientierte Analyse und Modellierung


Einer objektorientierten Programmierung geht die objektorientierte Analyse der Aufgabenstellung
voraus mit dem Ziel einer Modellierung durch kooperierende Klassen. Man identifiziert per Abs-
traktion die beteiligten Kategorien von Individuen bzw. Objekten und definiert für sie jeweils eine
Klasse.
In unserem Bruchrechnungsbeispiel ergibt sich bei der objektorientierten Analyse, dass vorläufig
nur eine Klasse zum Modellieren von Brüchen benötigt wird (Name: Bruch). Beim möglichen
2 Kapitel 1 Einleitung

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

Für die Bruch-Klasse erhält man die folgende Darstellung:

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

Weiterführende Informationen zur objektorientierten Analyse und Modellierung bieten z. B. Balzert


(2011) und Booch et al. (2007).

1.1.2 Objektorientierte Programmierung


In unserem einfachen Beispielprojekt soll nun die Klasse Bruch in der Programmiersprache Java
codiert werden, wobei die Felder (Eigenschaften) zu deklarieren und die Methoden zu implementie-
ren sind. Es resultiert der sogenannte Quellcode, der in einer Textdatei namens Bruch.java unter-
gebracht werden muss.
Zwar sind Ihnen die meisten Details der folgenden Klassendefinition selbstverständlich jetzt noch
fremd, doch sind die Felddeklarationen und Methodenimplementationen als zentrale Bestandteile
leicht zu erkennen. Außerdem sind Sie nach den ausführlichen Erläuterungen zur Datenkapselung
sicher an der technischen Umsetzung interessiert. Die beiden Felder (zaehler, nenner) werden
über den Modifikator private vor direkten Zugriffen durch fremde Klassen geschützt. Demgegen-
über werden die Methoden über den Modifikator public für die Verwendung in klassenfremden
Methoden freigegeben. Für die Klasse selbst wird mit dem Modifikator public die Verwendung in
beliebigen Java-Programmen erlaubt.1
public class Bruch {
private int zaehler; // wird automatisch mit 0 initialisiert
private int nenner = 1;

public void setzeZaehler(int z) {


zaehler = z;
}

public boolean setzeNenner(int n) {


if (n != 0) {
nenner = n;
return true;
} else
return false;
}

public int gibZaehler() {


return zaehler;
}

public int gibNenner() {


return nenner;
}

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

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;
}

public void addiere(Bruch b) {


zaehler = zaehler * b.nenner + b.zaehler * nenner;
nenner = nenner * b.nenner;
kuerze();
}
public void frage() {
int n;
do {
System.out.print("Zähler: ");
setzeZaehler(Simput.gint());
} while (Simput.checkError());
do {
System.out.print("Nenner: ");
n = Simput.gint();
if (n == 0 && !Simput.checkError())
System.out.println("Der Nenner darf nicht null werden!\n");
} while (n == 0);
setzeNenner(n);
}

public void zeige() {


System.out.printf(" %d\n -----\n %d\n", zaehler, nenner);
}
}
Allerdings ist das Programm schon zu umfangreich für die bald anstehenden ersten Gehversuche
mit der Software-Entwicklung in Java.
Wie Sie bei späteren Beispielen erfahren werden, dienen in einem objektorientierten Programm
beileibe nicht alle Klassen zur Modellierung des Aufgabenbereichs. Es sind auch Objekte aus der
Welt des Computers zu repräsentieren (z. B. Fenster der Bedienoberfläche, Netzwerkverbindungen,
Störungen des normalen Programmablaufs).

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 = tut sowie v = tvt
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 = tut sowie (u – v) = tdt
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.

1.1.4 Startklasse und main() - Methode


Bislang wurde im Einführungsbeispiel aufgrund einer objektorientierten Analyse des Aufgabenbe-
reichs die Klasse Bruch entworfen und in Java realisiert. Wir verwenden nun die Klasse Bruch in
einer Konsolenanwendung zur Addition von zwei Brüchen. Dabei bringen wir einen Akteur ins
Spiel, der in einer simplen Anweisungssequenz Bruch-Objekte erzeugt und ihnen Nachrichten zu-
stellt, die (zusammen mit dem Verhalten des Anwenders) den Programmablauf voranbringen.
In diesem Zusammenhang ist von Bedeutung, dass es in jedem Java - Programm eine Startklasse
geben muss, die eine Methode mit dem Namen main() in ihrem klassenbezogenen Handlungsreper-
toire besitzt. Beim Starten eines Programms wird die (aufgrund des Startkommandos bekannte)
Startklasse aufgefordert, ihre Klassenmethode main() auszuführen. Wegen der besonderen Rolle
dieser Methode ist die Bezeichnung Hauptmethode durchaus berechtigt.
Abschnitt 1.1 Beispiel für die objektorientierte Software-Entwicklung mit Java 11

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.

1.1.5 Zusammenfassung zum Abschnitt 1.1


Im Abschnitt 1.1 sollten Sie einen ersten Eindruck von der Software-Entwicklung mit Java gewin-
nen. Alle dabei erwähnten Konzepte der objektorientierten Programmierung und die technischen
Details der Realisierung in Java werden bald systematisch behandelt und sollten Ihnen daher im
Moment noch keine Kopfschmerzen bereiten. Trotzdem kann es nicht schaden, an dieser Stelle ei-
nige Kernaussagen von Abschnitt 1.1 zu wiederholen:
• Vor der Programmentwicklung findet die objektorientierte Analyse der Aufgabenstellung
statt. Dabei werden per Abstraktion die beteiligten Klassen identifiziert.
• Ein Programm besteht aus Klassen. Unsere Beispielprogramme zum Erlernen von elementa-
ren Java-Sprachelementen werden oft mit einer einzigen Klasse auskommen. Praxisgerechte
Programme bestehen in der Regel aus mehreren Klassen.
• Eine Klasse ist charakterisiert durch Eigenschaften (Felder) und Handlungskompetenzen
(Methoden).
• Eine Klasse dient in der Regel als Bauplan für Objekte, kann aber auch selbst aktiv werden
(Methoden ausführen und aufrufen).
• Ein Feld bzw. eine Methode ist entweder den Objekten einer Klasse oder der Klasse selbst
zugeordnet.
• In den Methodendefinitionen werden Algorithmen realisiert. Dabei kommen selbst erstellte
Klassen zum Einsatz, aber auch vordefinierte Klassen aus diversen Bibliotheken.
• Im Programmablauf kommunizieren die Akteure (Objekte und Klassen) durch den Aufruf
von Methoden miteinander, wobei in der Regel noch „externe Kommunikationspartner“
(z. B. Benutzer, andere Programme) beteiligt sind.
• Beim Programmstart wird die Startklasse vom Laufzeitsystem aufgefordert, die Methode
main() auszuführen. Ein Hauptzweck dieser Methode besteht oft darin, Objekte zu erzeugen
und somit „Leben auf die objektorientierte Bühne zu bringen“.
Abschnitt 1.2 Java-Programme ausführen 13

1.2 Java-Programme ausführen


Wer sich schon jetzt von der Nützlichkeit des im Abschnitt 1.1 vorgestellten Bruchadditionspro-
gramms überzeugen möchte, findet eine ausführbare Version an der im Vorwort angegebenen Stelle
im Ordner
...\BspUeb\Einleitung\Bruchaddition\Konsole

1.2.1 Java-Laufzeitumgebung installieren


Um das Programm auf einem Rechner ausführen zu können, muss dort eine Java Virtual Machine
(JVM), die auch als Java Runtime Environment (JRE) bezeichnet wird, mit hinreichend aktueller
Version (ab Java 8) installiert sein. Mit den technischen Grundlagen und Aufgaben dieser Ausfüh-
rungsumgebung für Java-Programme werden wir uns im Abschnitt 1.3.2 beschäftigen.
Um unter Windows festzustellen, ob eine JVM installiert ist, und welche Version diese besitzt, star-
tet man eine Eingabeaufforderung und schickt dort das folgende Kommando
>java -version
ab, z. B.:

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.:

Eine heruntergeladene und ausgeführte MSI-Datei installierte die neue ojdkbuild-Version


und entfernt die alte.
Am 20.10.2021 wird auf der oben angegebenen Webseite auch die LTS-Version von Java 11 ange-
boten:

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:

Nach der freundlichen Begrüßung im ersten Dialog des OpenJDK-Installationsprogramms


16 Kapitel 1 Einleitung

werden die Lizenzbedingungen vorgelegt:

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:

1.2.2 Konsolenprogramme ausführen


Nach der Installation einer Java-Laufzeitumgebung (JVM) machen wir uns endlich daran, das
Bruchadditionsprogramm zu starten. Kopieren Sie von der oben angegebenen Quelle
...\BspUeb\Einleitung\Bruchaddition\Konsole
die Dateien Bruch.class, Bruchaddition.class und Simput.class mit ausführbarem Java-Bytecode
(siehe Abschnitt 1.3.2) auf einen eigenen Datenträger.
Weil die Klasse Bruch wie viele andere im Manuskript verwendete Beispielklassen mit konsolen-
orientierter Benutzerinteraktion die nicht zur Java-Standardbibliothek gehörige Klasse Simput
verwendet, muss auch die Klassendatei Simput.class übernommen werden. Sobald Sie die zur Ver-
18 Kapitel 1 Einleitung

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

1.2.3 Ausblick auf Anwendungen mit grafischer Bedienoberfläche


Das seit dem Abschnitt 1.1 vorgestellte Beispielprogramm arbeitet der Einfachheit halber mit einer
konsolenorientierten Ein- und Ausgabe. Nachdem wir im Kurs bzw. Manuskript im Rahmen einfa-
cher Konsolenprogramme grundlegende Java-Sprachelemente kennengelernt haben, werden wir uns
natürlich auch mit der Programmierung von grafischen Bedienoberflächen beschäftigen. Im folgen-
den Programm zur Addition von Brüchen wird ebenfalls die Klasse Bruch verwendet, wobei an-
stelle ihrer Methoden frage() und zeige() jedoch grafikorientierte Techniken zum Einsatz
kommen:

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:

Im Eigenschaftsdialog zur fertiggestellten Verknüpfungsdatei ergänzt man in Feld Ziel hinter


javaw.exe den Namen der Startklasse und trägt im Feld Ausführen in den Ordner ein, in dem sich
die Startklasse befindet, z. B.:
Abschnitt 1.2 Java-Programme ausführen 21

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

auf Sicherheitsprobleme in der Java-Laufzeitumgebung zu reagieren. Auch in der Java - Software-


Technik kommen Sicherheitsprobleme vor, allerdings vergleichsweise selten (siehe dazu Abschnitt
1.3.6.3).

1.2.4 Ausführung auf einer beliebigen unterstützten Plattform


Dank der Portabilität (Binärkompatibilität) von Java können wir z. B. das im letzten Abschnitt vor-
geführte, unter Windows entwickelte grafische Bruchadditionsprogramm auch unter anderen Be-
triebssystemen ausführen, z. B. unter macOS. Wird die am Ende von Abschnitt 1.2.3 erwähnte Da-
tei Bruchaddition.jar auf einen Mac mit installierter Java-Laufzeitumgebung ab Version 8 (inkl.
JavaFX-Unterstützung) kopiert, dann lässt sich das Programm dort per Doppelklick starten. Es er-
scheint die vertraute Bedienoberfläche mit dem macOS - üblichen Fensterdekor:

1.3 Architektur und Eigenschaften von Java-Software


Bisher war von der Programmiersprache Java und gelegentlich etwas ungenau vom Laufzeitsystem
die Rede. Nach der Lektüre dieses Abschnitts werden Sie ein gutes Verständnis von den drei Säu-
len der Java-Softwaretechnik besitzen:
• Die Programmiersprache mit dem Compiler, der Quellcode in Bytecode (siehe Abschnitt
1.3.2) übersetzt
• Die Standardklassenbibliothek mit ausgereiften Lösungen für (fast) alle Routineaufgaben
• Die Java Virtual Maschine (JVM) mit zahlreichen Funktionen bei der Ausführung von
Bytecode (z. B. optimierender JIT-Compiler, Klassenlader, Sicherheitsüberwachung)
Im Abschnitt 1.3 stehen technische Merkmale der Java-Software in Vordergrund, doch werden auch
Lizenzfragen geklärt.

1.3.1 Herkunft und Bedeutung der Programmiersprache Java


Weil auf der indonesischen Insel Java eine auch bei Programmierern hoch geschätzte Kaffees-Sorte
wächst, kam die in diesem Manuskript vorzustellende Programmiersprache Gerüchten zufolge zu
ihrem Namen.
Java wurde ab 1990 von einem Team der Firma Sun Microsystems unter Leitung von James Gos-
ling entwickelt. Nachdem erste Pläne zum Einsatz in Geräten aus dem Bereich der Unterhaltungs-
elektronik (z. B. Set-Top-Boxen für TV-Geräte) wenig Erfolg brachten, orientierte man sich stark
am boomenden Internet. Das zuvor auf die Darstellung von Texten und Bildern beschränkte WWW
(Word Wide Web) wurde um die Möglichkeit bereichert, kleine Java-Programme (Applets genannt)
von einem Server zu laden und ohne lokale Installation im Fenster des Internet-Browsers auszufüh-
ren. Ein erster Durchbruch gelang 1995, als die Firma Netscape die Java-Technologie in die Versi-
on 2.0 ihres WWW-Navigators integrierte. Kurze Zeit später wurden mit der Version 1.0 des Java
Development Kits Werkzeuge zum Entwickeln von Java-Applets und -Anwendungen frei verfüg-
bar.
Abschnitt 1.3 Architektur und Eigenschaften von Java-Software 23

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.3.2 Quellcode, Bytecode und Maschinencode


Im Abschnitt 1.1 haben Sie Java als eine Programmiersprache kennengelernt, die Ausdrucksmittel
zur Modellierung des Anwendungsbereichs und zur Formulierung von Algorithmen bereitstellt.
Unter einem Programm wurde dabei der vom Entwickler zu formulierende Quellcode verstanden.
Während Sie derartige Texte bald mit Leichtigkeit lesen und begreifen werden, kann die CPU
(Central Processing Unit) eines Rechners nur einen maschinenspezifischen Satz von Befehlen ver-
stehen, die als Folge von Nullen und Einsen (= Maschinencode) formuliert werden müssen. Die
ebenfalls CPU-spezifische Assembler-Sprache stellt eine für Menschen besser lesbare Form des
Maschinencodes dar. Mit dem Assembler- bzw. Maschinenbefehl
mov eax, 4
einer CPU aus der x86-Familie wird z. B. der Wert 4 in das EAX-Register (ein Speicherort im Pro-
zessor) geschrieben. Die CPU holt sich einen Maschinenbefehl nach dem anderen aus dem Haupt-
speicher und führt ihn aus, wobei heutzutage (2021) die CPU eines handelsüblichen Arbeitsplatz-
rechners (mit GHz-Taktfrequenz und zahlreichen Kernen/Threads) mehrere hundert Milliarden Be-
fehle pro Sekunde (Instructions Per Second, IPS) schafft.1 Ein Quellcode-Programm muss also erst
in Maschinencode übersetzt werden, damit es von einem Rechner ausgeführt werden kann. Dies
geschieht bei Java aus Gründen der Portabilität und Sicherheit in zwei Schritten:

1
https://de.wikipedia.org/wiki/Instruktionen_pro_Sekunde
https://en.wikipedia.org/wiki/Instructions_per_second
24 Kapitel 1 Einleitung

Übersetzen: Quellcode → Bytecode


Der (z. B. mit einem beliebigen Texteditor verfasste) Quellcode wird vom Java-Compiler in den
Bytecode übersetzt. Dieser besteht aus den Befehlen einer von der Firma Sun Microsystems bzw.
vom Nachfolger Oracle definierten virtuellen Maschine, die sich durch ihren vergleichsweise ein-
fachen Aufbau gut auf aktuelle Hardware-Architekturen abbilden lässt. Wenngleich der Bytecode
von den heute üblichen Prozessoren noch nicht direkt ausgeführt werden kann, hat er doch bereits
die meisten Verarbeitungsschritte auf dem Weg vom Quell- zum Maschinencode durchlaufen. Sein
Name geht darauf zurück, dass die Instruktionen der virtuellen Maschine jeweils genau ein Byte (=
8 Bit) lang sind.
Aufgrund der geistigen Leistung der Java-Designer dürfen sich die Software-Entwickler Klassen
und Objekte vorstellen, die durch ihre freundliche und kompetente Interaktion auf der objektorien-
tierten Bühne in Kooperation mit dem Benutzer den Programmablauf voranbringen. Dieses mit un-
serer Alltagserfahrung verwandte Modell hilft uns bei der Lösung komplexer Aufgaben durch
Computer-Programme. Tatsächlich werden in einem Computer aber elementare binäre Daten zwi-
schen Speicherstationen bewegt und durch Operationen modifiziert:
• durch arithmetischen Operationen (Addition, Subtraktion, Multiplikation, Division)
• durch logische Operationen (OR, AND, NOT, exklusives OR)
Vom objektorientierten Quellcode bis zum Bytecode mit den Befehlen einer virtuellen Maschine,
die elementare Operationen auf binäre Daten anwendet, hat ein Java-Compiler eine immense Über-
setzungsarbeit zu leisten. Wir bewegen uns meist im objektorientierten Modell, müssen aber gele-
gentlich doch die reale Computer-Technik berücksichtigen (z.B. die Anzahl der vorhandenen CPU-
Kerne bei der Multithreading-Programmierung für die parallele Programmausführung).
Ansätze zur Entwicklung von realen Java-Prozessoren, die Bytecode direkt (in Hardware) ausfüh-
ren können, haben bislang keine nennenswerte Bedeutung erlangt. Die CPU-Schmiede ARM, deren
Prozessoren auf mobilen und eingebetteten Systemen stark verbreitetet sind, hat eine Erweiterung
namens Jazelle DBX (Direct Bytecode eXecution) entwickelt, die zumindest einen großen Teil der
Bytecode-Instruktionen in Hardware unterstützt.1 Allerdings macht das auf Geräten mit ARM-
Prozessor oft eingesetzte (und überwiegend mit der Ausführung von Java-Bytecode beschäftigte)
Betriebssystem Android der Firma Google von Jazelle DBX keinen Gebrauch. In aktuellen ARM-
Prozessoren spielt die mittlerweile als veraltet und überflüssig betrachtete Jazelle-Erweiterung keine
Rolle mehr (Langbridge 2014, S. 48).
Das kostenlos verfügbare Java Development Kit (JDK), das von der Firma Oracle und der Java-
Community gemeinsam entwickelt wird, enthält einen Compiler (unter Windows in der Datei ja-
vac.exe), den auch Java-Entwicklungsumgebungen im Hintergrund einsetzen, z. B. die im Kurs
bevorzugte Entwicklungsumgebung IntelliJ IDEA. Die OpenJDK 8 - Distribution aus dem
ojdkbuild-Projekt haben wir schon im Abschnitt 1.2.1 installiert. Im Abschnitt 2.1 folgt noch die
OpenJDK-Version 17.
Quellcode-Dateien tragen in Java die Namenserweiterung .java, Bytecode-Dateien die Erweiterung
.class.

Interpretieren: Bytecode → Maschinencode


Abgesehen von den seltenen Systemen mit realem Java-Prozessor muss für jede Betriebssys-
tem/CPU - Kombination mit Java-Unterstützung ein (naturgemäß plattformabhängiger) Interpreter
erstellt werden, der den Bytecode zur Laufzeit in die jeweilige Maschinensprache übersetzt. Man
verwendet die eben im Sinne eines Quasi-Hardware - Designs eingeführte Bezeichnung virtuelle
Maschine (Java Virtual Machine, JVM) auch für die an der Ausführung von Java-Bytecode betei-

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

Quellcode Bytecode Maschinencode

public class Bruch {


... Bruch.class
}
Interpreter
public class Simput {
Compiler, (virtuelle
... z.B.
} Simput.class Maschine), Maschinencode
javac.exe
z.B.
class Bruchaddition {
... java.exe
}
Bruchaddition.class

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.3.4 Java-Editionen für verschiede Einsatzszenarien


Weil die Java-Plattform so mächtig und vielgestaltig geworden ist, wurden drei Editionen für spezi-
elle Einsatzfelder definiert, wobei sich vor allem die jeweiligen Standardklassenbibliotheken unter-
scheiden:
• Java Standard Edition (JSE) zur Entwicklung von Software für Arbeitsplatzrechner
Darauf wird sich das Manuskript beschränken.
• Java bzw. Jakarta Enterprise Edition (JEE) für unternehmensweite oder serverorientierte
Lösungen
Bei der Java Enterprise Edition (JEE) kommt exakt dieselbe Programmiersprache wie bei
der Java Standard Edition (JSE) zum Einsatz. Für die erweiterte Funktionalität sorgt eine
entsprechende Variante der Standardklassenbibliothek. Beide Editionen verfügen über eine
eigenständige Versionierung (im Oktober 2021: JSE 17 und JEE 9). Die JEE ist im Herbst
2017 von der Firma Oracle an die Open Source Community (vertreten durch die Eclipse
Foundation) übergeben worden und heißt seitdem Jakarta EE.1 Als Alternative zur JEE für
die Entwicklung von Java-Unternehmenslösungen hat sich das Spring-Framework etabliert.2

1
https://jakarta.ee/
2
https://de.wikipedia.org/wiki/Spring_(Framework)
28 Kapitel 1 Einleitung

• Java Micro Edition (JME) für Kommunikationsgeräte und eingebettete Lösungen


Diese Edition wurde einst für Mobiltelefone mit beschränkter Leistung konzipiert. Bei heu-
tigen Smartphones kann aber von eingeschränkter Leistung kaum noch die Rede sein, und
die JME ist dementsprechend ins Hintertreffen geraten. Neben großer Skepsis zu den Über-
lebenschance der JME gibt es Bestrebungen zu einer Neuausrichtung für den Einsatz bei
eingebetteten Lösungen (Stichwort: Internet der Dinge).1 Die aktuelle Version 8.3 stammt
aus dem Jahr 2018.
Wir werden uns im Manuskript weder mit der JEE noch mit der JME beschäftigen, doch sind er-
worbene Java-Programmierkenntnisse natürlich dort uneingeschränkt verwendbar, und elementare
Klassen der JSE-Standardbibliothek sind auch in den anderen Editionen verfügbar.
Weil sich die Standardklassenbibliotheken der Editionen unterschieden, muss man z. B. vom JSE-
API sprechen, wenn man die JSE-Standardbibliothek meint. Im Manuskript, das sich auf die JSE
beschränkt, wird gelegentlich für das JSE-API etwas ungenau die Bezeichnung Java-API verwen-
det.
Im Marktsegment der Smartphones und Tablet-Computer hat sich eine Entwicklung vollzogen, die
die ursprüngliche Konzeption der Java-Editionen durcheinander gewirbelt hat. Einfache Mobiltele-
fone wurden von Smartphones mit GHz-Prozessoren verdrängt. Während die Firma Apple bisher in
ihrem Betriebssystemen für Smartphones (iOS) und Tablets (iPadOS) keine Java-Unterstützung
bietet, hat der Konkurrent Google in seinem Smartphone - und Tablet - Betriebssystem Android
Java lange als Standardsprache zur Anwendungsentwicklung eingesetzt. Neuerdings tendiert
Google zur Programmiersprache Kotlin, die vom IntelliJ-Urheber JetBrains entwickelt wird, doch
ist kein Ende des Java-Supports in Android abzusehen. Kotlin wird wie Java in Bytecode für eine
virtuelle Maschine übersetzt, sodass eine hohe Kompatibilität zwischen den beiden Programmier-
sprachen besteht. In Android kommt eine alternative Bytecode-Technik zum Einsatz mit einer vir-
tuellen Maschine namens Dalvik (bis Android 4.4) bzw. ART (seit Android 5.0).2
Es spricht für das Potential von Java, dass diese Sprache auch eine sehr wichtige Rolle bei der Ent-
wicklung von Android-Apps spielt. Android trägt erheblich zur Attraktivität von Java bei, denn
Android besitzt auf dem Markt für Smartphone-Betriebssysteme einen Marktanteil von über 70%3
und ist auch auf dem Tablet-Markt mit einem Anteil von ca. 50% erfolgreich.4 Somit ist Android
eine sehr relevante Plattform für die klientenseitige Java-Programmierung. Mit den Lernerfahrun-
gen aus dem Kurs bzw. Manuskript können Sie zügig in die Software-Entwicklung für Android
einsteigen, müssen sich aber mit einer speziellen Software-Architektur auseinandersetzen, die zum
Teil aus der Smartphone-Hardware resultiert (z. B. kleines Display, Zwang zum Energiesparen we-
gen der begrenzten Akkukapazität). Auch zur Einführung in die Entwicklung von Android-Apps in
Java bietet das ZIMK ein Manuskript an (Baltes-Götz 2018).

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.3.5 Update- und Lizenzpolitik der Firma Oracle


Seit Java 7 ist das OpenJDK (mit Compiler, Laufzeitumgebung und Standardbibliothek) die offizi-
elle Referenzimplementation der Java Standard Edition. An der Weiterentwicklung ist neben der
Firma Oracle auch die Java-Community beteiligt, zu der u. a. zahlreiche namhafte Firmen gehören
(z. B. Red Hat1, IBM2, Microsoft3). Das OpenJDK steht unter der liberalen (GPLv2 & CPE) - Li-
zenz und darf frei verwendet werden.4
Allerdings ist die OpenJDK-Unterstützung durch Oracle auf die 6 Monate bis zum Erscheinen der
nächsten Java-Hauptversion beschränkt. Von der Firma Oracle ist also stets ein aktuelles und siche-
res OpenJDK zu beziehen, doch muss man alle 6 Monate auf eine neue Hauptversion umsteigen.
Ein problemloses Update ist zwar wahrscheinlich, aber nicht garantiert. Wird das OpenJDK zu-
sammen mit einer eigenen Anwendung ausgeliefert, muss den Kunden ein Update-Verfahren auf
die neue Java-Hauptversion angeboten werden.
OpenJDK-Hauptversionen erscheinen seit der Version 9 in einem halbjährlichen Release-Zyklus,
um die Weiterentwicklung zu beschleunigen.5 Die jeweils aktuelle Version wird nur ein halbes Jahr
lang (bis zum Erscheinen der nächsten Hauptversion) mit Updates versorgt. So sind wir aktuell
(Oktober 2021) bei Version 17 angekommen.
Für einige Java-Versionen wird ein Long Term Support (LTS) zugesichert (aktuell für die Versio-
nen 8, 11 und Version 17), sodass die Versorgung mit (Sicherheits-)Updates über einen Zeitraum
von mindestens 5 Jahren garantiert ist. Damit bestehen für Entwickler und Anwender stabile Ver-
hältnisse, und es fallen lediglich (Sicherheits-)Updates ohne Kompatibilitätsrisiko an. Während die
Firma Oracle bei LTS-Versionen nach Ablauf des 6-monatigen Standard-Supports für die nicht-
private Verwendung Lizenzgebühren verlangt, versorgen andere Firmen die LTS-Versionen ohne
Kosten und Lizenzeinschränkungen mit Updates. In der Regel werden dabei verschiedene Betriebs-
systeme unterstützt und bequeme Installationsprogramme geliefert (siehe Abschnitt 2.1.2).
Java ist trotz der neuen Lizenzpolitik der Firma Oracle eine frei verfügbare Entwicklungsplattform,
wenngleich der Zugriff auf eine mit Updates versorgte Version nun für Entwickler und Anwender
etwas mehr Aufmerksamkeit erfordert. Es ist in der IT-Industrie durchaus üblich, dass mit Open
Source - Software Geld verdient wird, und man kann es der Firma Oracle nicht verdenken, dass sie
sich auch um ihre Bilanzen kümmert.

1.3.6 Eigenschaften von Java-Software


In diesem Abschnitt werden zentrale Eigenschaften der Java-Software beschrieben, wobei Vorgriffe
auf später ausführlich behandelte Themen nicht zu vermeiden sind.

1.3.6.1 Objektorientierung mit funktionalen Erweiterungen


Java wurde als objektorientierte Sprache konzipiert und erlaubt im Unterschied zu hybriden Spra-
chen wie C++ oder Delphi außerhalb von Klassendefinitionen keine Anweisungen. In unserem Ein-
leitungsbeispiel wurde einiger Aufwand in Kauf genommen, um einen realistischen Eindruck von
objektorientierter Programmierung (OOP) zu vermitteln (siehe Abschnitt 1.1). Oft trifft man auf
Einleitungsbeispiele, die zwar angenehm einfach aufgebaut sind, aber außer gewissen Formalitäten

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

In aktuelleren BSI-Jahresberichten ist leider kein vergleichbarer Überblick enthalten.

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.4 Übungsaufgaben zum Kapitel 1


1) Warum steigt die Produktivität der Software-Entwicklung durch objektorientiertes Programmie-
ren?

2) Welche der folgenden Aussagen sind richtig bzw. falsch?


1. Die Programmiersprache Java ist relativ leicht zu erlernen, weil beim Design Einfachheit
angestrebt wurde.
2. In Java muss jede Klasse eine Methode namens main() enthalten.
3. Die meisten aktuellen CPUs können Java-Bytecode direkt ausführen.
4. Java eignet sich für eine sehr breite Palette von Anwendungen, von Smartphone-Apps über
Anwendungsprogramme für Arbeitsplatzrechner bis zur unternehmenswichtigen Server-
Software.
2 Werkzeuge zum Entwickeln von Java-Programmen
In diesem Kapitel werden kostenlose Werkzeuge zum Entwickeln von Java-Anwendungen be-
schrieben. Zunächst beschränken wir uns puristisch auf einen Texteditor und das Java Develop-
ment Kit (Standard Edition). In dieser sehr übersichtlichen „Entwicklungsumgebung“ werden die
grundsätzlichen Arbeitsschritte und einige Randbedingungen besonders deutlich.
Anschließend gönnen wir uns erheblich mehr Luxus in Form der Open Source - Entwicklungsum-
gebung IntelliJ IDEA Community, die auf vielfältige Weise die Programmentwicklung unter-
stützt. IntelliJ IDEA bietet u. a.:
• einen Editor mit ...
o farblicher Unterscheidung verschiedener Syntaxbestandteile
o Vorschläge zur Syntaxerweiterung
o Unterschlängeln von Fehlern
o Refaktorierungs-Unterstützung (z.B. beim Umbenennen von Klassen oder Variablen)
o usw.
• einen Debugger zur Unterstützung bei der Fehlersuche
• Assistenten zum automatischen Erstellen von Quellcode für Routineaufgaben
Anschließend werden die für Kursteilnehmer bzw. Leser empfohlenen Installationen beschrieben.
Alle Pakete sind kostenlos für alle relevanten Betriebssysteme verfügbar.

2.1 Aktuelles OpenJDK installieren


Wir haben im Abschnitt 1.2.1 bereits ein Java Development Kit installiert, waren dabei aber in ers-
ter Linie an der enthaltenen Java Virtual Machine (JVM) interessiert. Unsere Entscheidung fiel auf
das OpenJDK in der Version 8 (alias 1.8), zu der das ojdkbuild-Projekt eine komfortable Distributi-
on mit folgenden Vorteilen pflegt:
• Kein relevante Lizenzeinschränkung
• Langzeit-Support bis Mai 2026
• JavaFX (alias OpenJFX) als Option enthalten
• Update-Unterstützung
Das JDK erscheint seit der Version 9 in einem halbjährlichen Release-Zyklus, um die Weiterent-
wicklung zu beschleunigen.1 So sind wir aktuell (im Oktober 2021) bei der Version 17 angekom-
men. Obwohl der Abstand zwischen den Versionen 17 und 8 weniger gewaltig ist, als es der nume-
rische Unterschied vermuten lässt, sind doch einige Neuerungen der Java-Softwaretechnik bzw. der
Java-Programmiersprache für uns relevant, z. B.:
• das mit Java 9 eingeführte Modulsystem
• die mit Java 10 eingeführte Typinferenz für lokale Variablen
• die mit Java 11 eingeführte Erweiterung der Typinferenz für Lambda-Parameter
• die mit Java 14 eingeführten switch-Ausdrücke
• die mit Java 15 eingeführten Textblöcke
• die mit Java 16 eingeführten Record-Typen
• die in Java 16 vorgenommene Verbesserung des instanceof-Operators durch Musterverglei-
che (engl. pattern matching)
• die mit Java 17 eingeführten versiegelten Klassen und Schnittstellen
• die mit Java 17 (allerdings nur als Preview) auch für switch-Ausdrücke eingeführten Mus-
tervergleiche

1
https://jaxenter.de/java-jdk-release-zyklus-75402
36 Kapitel 2 Werkzeuge zum Entwickeln von Java-Programmen

Daher werden wir im Kurs auch mit dem OpenJDK 17 arbeiten.

2.1.1 OpenJDK 17 der Firma Oracle


Die OpenJDK 17 - Distribution der Firma Oracle ist auf dieser Webseite
https://jdk.java.net/17/
frei verfügbar und ermöglicht es uns, die aktuelle, am 14.09.2021 erschienene Java-Version 17 zu
verwenden, die auch von unserer Entwicklungsumgebung IntelliJ IDEA 2021.2 unterstützt wird
(siehe Abschnitt 2.3). Das OpenJDK von Oracle steht unter der (GPLv2 & CPE) - Lizenz und darf
zur Software-Entwicklung sowie im produktiven Einsatz frei verwendet werden. Allerdings ist die
Unterstützung durch Updates auf die 6 Monate bis zum Erscheinen der nächsten Java-Hauptversion
beschränkt. Die Java-Version 17 wird eine Langzeitunterstützung durch Updates erhalten (LTS,
Long Term Support), die bei Oracle für die nicht-private Nutzung kostenpflichtig ist, nach den bis-
herigen Erfahrungen mit den LTS-Versionen 8 und 11 bei anderen Anbietern (z.B. Amazon, Micro-
soft, Red Hat) aber sehr wahrscheinlich kostenlos zu haben sein wird (siehe Abschnitt 2.1.2).
Eine OpenJDK-Installation auf Basis der Oracle-Distribution erfordert etwas Handarbeit, hat aber
den Vorteil der hohen Transparenz. Anschließend wird die Installation unter Windows 10 (64 Bit)
beschrieben:
• Laden Sie von der oben genannten Webseite das für Windows x64 angebotene ZIP-Archiv
mit der aktuellen Version herunter. Am 20.10.2021 wird die Version 17.0.1 in der Datei
openjdk-17.0.1_windows-x64_bin.zip geliefert.
• Beim Auspacken des ZIP-Archivs entsteht der Ordner jdk-17.0.1.
• Erstellen Sie bei Bedarf den Ordner C:\Program Files\Java.
• Kopieren Sie den OpenJDK-Ordner unter dem Namen OpenJDK-17 in den Ordner
C:\Program Files\Java:

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.1.2 OpenJDK-Distributionen mit langfristiger Update-Versorgung


Wer mit dem OpenJDK der Firma Oracle arbeitet, ist stets auf dem neuesten Stand, muss aber alle 6
Monate auf eine neue Hauptversion umsteigen. Für die LTS-Versionen (Long Term Support) von
Java (aktuell die Versionen 8, 11 und 17) wird die Versorgung mit Updates über einen Zeitraum
von mindestens 5 Jahren zugesichert. Während die Firma Oracle für LTS-Versionen bei nicht-
privater Verwendung Lizenzgebühren verlangt, ist der Service bei anderen Anbietern kostenlos zu
haben (vgl. Abschnitt 1.3.5). Ohne Anspruch auf Vollständigkeit sollen genannt werden:
• OpenJDK aus dem ojdkbuild-Projekt
Das von der Firma Red Hat, einem Anbieter professioneller Linux-Lösungen, gesponserte
Open Source - Projekt ojdkbuild bietet auf der Webseite
https://github.com/ojdkbuild/ojdkbuild
u. a. OpenJDK-Distributionen mit den LTS-Versionen 8 und 11 an. Wir haben im Abschnitt
1.2.1 das OpenJDK 8 aus dem ojdkbuild-Projekt installiert.
• Amazon Corretto
Die Firma Amazon bietet auf der Webseite
https://aws.amazon.com/de/corretto/
unter dem Namen Corretto OpenJDK-Distributionen mit den LTS-Versionen 8 und 11 an.
• OpenJDK Build von Microsoft
Die Firma Microsoft bietet auf der Webseite
https://docs.microsoft.com/en-us/java/openjdk/download
u. a. eine OpenJDK-Distribution mit der LTS-Version 11 an.
Sehr wahrscheinlich werden alle Anbieter in Kürze eine OpenJDK-Distribution mit der LTS-
Version 17 bereithalten.
Leider besitzen die OpenJDK-Distributionen mit Ausnahme der OpenJDK 8 - Distribution aus dem
ojdkbuild-Projekt in der Regel keine automatische Update-Unterstützung, sodass sich Entwickler
und Anbieter über Sicherheitsupdates informieren müssen.

2.2 Java-Entwicklung mit dem JDK und einem Texteditor

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

• Die Dateinamenserweiterung muss .java lauten.


• Der Dateinamensstamm (vor dem Punkt) sollte unbedingt mit dem Klassennamen überein-
stimmen. Ein aus der Missachtung dieser Regel resultierendes Problem ist im Abschnitt
2.2.2 zu sehen. Die vom Compiler erzeugte Bytecode-Datei übernimmt auf jeden Fall den
Namen der Klasse. Es resultiert also eine Namensabweichung zwischen Quellcode- und
Bytecode-Datei, wenn die Quellcodedatei nicht den Namen der Klasse (mit angehängter
Erweiterung .java) trägt.
• Unter Windows ist beim Dateinamen die Groß-/Kleinschreibung zwar irrelevant, doch sollte
auch hier auf exakte Übereinstimmung mit dem Klassennamen geachtet werden.

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.:

Wenn Sie im Compiler-Aufruf den Ordnernamen weglassen,


>javac Hallo.java
dann kommt vermutlich der Compiler der gemäß Abschnitt 1.2.1 installierten OpenJDK-Version 8
zum Zug, der im Windows-Suchpfad für ausführbare Programme enthalten ist. Bei den im Ab-
schnitt 2.2 beschriebenen Übungen verhalten sich die OpenJDK-Versionen 8 und 17 gleich.
Damit unter Windows der Compiler aus dem OpenJDK 17 ohne Pfadangabe von jedem Verzeichnis
aus gestartet werden kann, muss das bin-Unterverzeichnis der OpenJDK 17 - Installation (mit dem
Compiler javac.exe) vorrangig in die Definition der Umgebungsvariablen PATH aufgenommen
werden, was unter Windows 10 z. B. so geschehen kann:1
• Suchen: Umgebungsvariablen
• Starten: Systemumgebungsvariablen bearbeiten

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

• Klick auf Umgebungsvariablen


Bei der Umgebungsvariablen PATH erlaubt Windows eine System- und eine Benutzervari-
ante, wobei die Systemvariable Vorrang hat. Enthält die (immer vorhandene) Systemvariab-
le PATH einen Ordner mit javac.exe (z. B. aufgrund der im Abschnitt 1.2.1 beschriebenen
Installation der OpenJDK-Version 8), dann muss diese Variable modifiziert werden, was an-
schließend beschrieben wird. Ansonsten genügt es, das bin-Unterverzeichnis der zu ver-
wendenden OpenJDK -Installation in die PATH-Benutzervariable einzutragen.
• Systemvariable PATH Bearbeiten
• Neuen Eintrag mit C:\Program Files\Java\OpenJDK-17\bin erstellen und ganz nach
oben befördern
Beim Kompilieren des Quellcodes zu einer Klasse A wird auch der Quellcode zu einer in A benutz-
ten Klasse B neu übersetzt, wenn ...
• der Quellcode zu B im aktuellen Ordner vorhanden, der Bytecode zu B aber nicht verfügbar
ist,
• der Quellcode zu B im aktuellen Ordner vorhanden und der Bytecode zu B verfügbar ist (im
aktuellen Ordner vorhanden oder via CLASSPATH auffindbar, vgl. Abschnitt 2.2.4), wobei
die Bytecode-Datei älter ist als die Quellcodedatei
Sind etwa im Bruchadditionsbeispiel die Quellcodedateien Bruch.java und Bruchaddition.java
geändert worden, dann genügt der folgende Compiler-Aufruf, um beide Dateien neu zu übersetzen:
>javac Bruchaddition.java
Die benötigten Quellcode-Dateinamen (z. B. Bruch.java) konstruiert der Compiler aus den ihm
bekannten Klassenbezeichnungen (z. B. Bruch). Bei Missachtung der Quellcodedatei-Benennungs-
regeln (siehe Abschnitt 2.2.1) muss der Compiler bei seiner Suche also scheitern.
Man kann den OpenJDK-Compiler javac.exe über das Jokerzeichen * auch beauftragen, alle Java-
Quellcodedateien zu übersetzen, z. B.:
>javac *.java
Bei der Ausführung der Klasse Bruchaddition zeigt sich ein Problem mit der Codierung bzw.
Darstellung der deutschen Umlaute:

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:

Beim Programmstart ist zu beachten:


• Beim Aufruf des Interpreters wird der Name der auszuführenden Klasse als Argument ange-
geben, nicht der zugehörige Dateiname. Wer den Dateinamen (samt Namenserweiterung
.class) angibt, sieht eine Fehlermeldung:

• 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.

2.2.4 Suchpfad für class-Dateien


Compiler und Interpreter benötigen Zugriff auf die Bytecode-Dateien der Klassen, die im zu über-
setzenden Quellcode bzw. im auszuführenden Programm angesprochen werden und nicht als Quell-
code vorliegen. Mit Hilfe der Umgebungsvariablen CLASSPATH kann man eine Liste von Ver-
zeichnissen, JAR-Archiven (siehe Abschnitt 6.1.3) oder ZIP-Archiven spezifizieren, die nach class-
Dateien durchsucht werden sollen, z. B.:

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

2.2.5 Programmfehler beheben


Die vielfältigen Fehler, die wir mit naturgesetzlicher Unvermeidlichkeit beim Programmieren ma-
chen, kann man einteilen in:
• Syntaxfehler
Diese verstoßen gegen eine Syntaxregel der verwendeten Programmiersprache, werden vom
Compiler reklamiert und sind daher leicht zu beseitigen.
• Logikfehler (Semantikfehler)
Hier liegt kein Syntaxfehler vor, aber das Programm verhält sich anders als erwartet, wie-
derholt z. B. ständig eine nutzlose Aktion („Endlosschleife“) oder stürzt mit einem Laufzeit-
fehler ab. In jedem Fall sind die Benutzer verärgert.
Die Java-Urheber haben dafür gesorgt, dass möglichst viele Fehler vom Compiler aufgedeckt wer-
den können. Während Syntaxfehler nur den Programmierer betreffen, automatisch entdeckt und
leicht beseitigt werden können, verursachen Logikfehler für Entwickler und Anwender oft einen
sehr großen Schaden. Simons (2004, S. 43) schätzt, dass viele Logikfehler tausendfach mehr Auf-
wand verursachen als der übelste Syntaxfehler.
Wir wollen am Beispiel eines provozierten Syntaxfehlers überprüfen, ob der JDK-Compiler hilfrei-
che Fehlermeldungen produziert. Wenn im Hallo-Programm der Klassenname System fälschli-
cherweise mit kleinem Anfangsbuchstaben geschrieben wird,
class Hallo {
public static void main(String[] args) {
system.out.println("Hallo allerseits!");
}
}

dann führt ein Übersetzungsversuch zur folgenden Reaktion:

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

public boolean setzeNenner(int n) {


if (n == 0) {
nenner = n;
return true;
} else
return false;
}
In der main() - Methode der folgenden Klasse UnBruch erhält ein Bruch-Objekt aufgrund der un-
tauglichen Absicherung den kritischen Nennerwert 0 und wird anschließend zum Kürzen aufgefor-
dert:
class UnBruch {
public static void main(String[] args) {
Bruch b = new Bruch();
b.setzeZaehler(1);
b.setzeNenner(0);
b.kuerze();
}
}
Das Programm lässt sich fehlerfrei übersetzen, zeigt aber ein unerwünschtes Verhalten. Es gerät in
eine Endlosschleife (siehe unten) und verbraucht dabei reichlich Rechenzeit, wie der Windows-
Taskmanager (auf einem PC mit dem Intel-Prozessor Core i3 mit Dual-Core - Hyper-Threading-
CPU, also mit 4 logischen Kernen) belegt. Das Programm kann aufgrund seiner Single-Thread-
Technik nur einen logischen Kern nutzen und lastet diesen voll aus, sodass ca. 25% der CPU-
Leistung ver(sch)wendet werden:

Ein derart außer Kontrolle geratenes Konsolenprogramm kann man unter Windows z. B. mit der
Tastenkombination Strg+C beenden.

2.3 IntelliJ IDEA Community installieren


Die Community Edition der Entwicklungsumgebung IntelliJ IDEA wird von der Firma JetBrains
auf der Webseite
https://www.jetbrains.com/idea/download/
unter der Apache-Lizenz 2.0 kostenlos angeboten. Wir werden im Manuskript meist den zweiten
Namensbestandteil weglassen und von der Entwicklungsumgebung IntelliJ sprechen.
Zu jeder IntelliJ-Version kann das Plugin EduTools jederzeit kostenlos nachgerüstet werden. Unter
dem Namen IntelliJ IDEA Edu bietet JetBrains eine Community-Version mit integriertem Edu-
Tools-Plugin an. Vermutlich werden wir das Plugin nicht benötigen.
Abschnitt 2.3 IntelliJ IDEA Community installieren 47

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:

Ein Motiv für die Änderung des vorgeschlagenen Installationsordners

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

C:\Program Files\JetBrains\IntelliJ IDEA Community Edition 2021.2


Von den angebotenen Installationsoptionen ist vor allem Add "Open Folder as Project" in der
Praxis von Nutzen:

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

legt das Installationsprogramm los


Abschnitt 2.3 IntelliJ IDEA Community installieren 49

und endet nach wenigen Minuten mit der Erfolgsmeldung:

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

2.4 Java-Entwicklung mit IntelliJ IDEA


Wir müssen uns natürlich mit unserem wichtigsten Werkzeug, der Entwicklungsumgebung, vertraut
machen, haben aber nicht die Zeit, um eines der über IntelliJ geschriebenen Bücher komplett zu
studieren.

2.4.1 Erster Start


Die Verknüpfung zum Starten von IntelliJ IDEA findet man unter Windows 10 in der Startme-
nügruppe JetBrains. Beim ersten Start müssen die Lizenzbedingungen akzeptiert werden:

Dann bittet JetBrains um die Erlaubnis, diagnostische Informationen zur Verwendung von IntelliJ
anonym übertragen zu dürfen:

Anschließend wird die Erkennungsgrafik der gestarteten IntelliJ-Version angezeigt:


Abschnitt 2.4 Java-Entwicklung mit IntelliJ IDEA 51

Ggf. wird die Übernahme von Einstellungen einer früheren Version angeboten:

Nun erscheint der Welcome-Dialog mit dem voreingestellten Dracula-Farbschema:

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.

2.4.2 Projekt anlegen


Wir legen ein neues Projekt aus der voreingestellten Kategorie Java an:

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():

Im Project-Fenster sind u. a. zu sehen:


• HalloIntelliJ
Dieser Knoten (mit dem Symbol ) repräsentiert das Projekt und das primäre (automatisch
erstellte) Modul. Ein IntelliJ-Projekt kann mehrere Module enthalten, z. B. bei einer Server-
Client - Lösung jeweils ein Modul für die Server- und die Client-Komponente.1 Bei den In-
telliJ-Projekten im Kurs werden wir aber meist mit einem IntelliJ-Modul auskommen. Das
primäre Modul hat per Voreinstellung denselben Namen wie das Projekt, kann aber umbe-
nannt werden. Dann erscheint der Modulname hinter dem Projektnamen zwischen eckigen
Klammern.

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.

2.4.3.2 Code-Inspektion und Quick-Fixes


IntelliJ führt Code-Inspektionen on the fly durch, macht auf potentielle Probleme aufmerksam und
schlägt QuickFix-Korrekturen vor.
Wenn IntelliJ IDEA eine Code-Änderung vorschlagen möchte, dann erscheint eine gelbe Birne
links neben der betroffenen Stelle, z. B.:

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.

2.4.3.3 Live Templates


Häufig benötigte Code-Schnipsel (z. B. System.out.println();) kann IntelliJ über sogenannte
Live Templates (neudeutsch: Live-Vorlagen) produzieren. Wenn Sie eine neue Zeile mit dem Buch-
staben s starten, dann erscheint eine Liste mit allen durch Vervollständigung herstellbaren Live
Templates. Nötigenfalls kann die Liste mit der Tastenkombination Strg + J angefordert werden.
Wählen Sie aus der Liste

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.:

Zum selben Ziel kommt man auch ohne Mausbeteiligung:


• Einfügemarke auf den interessierenden Bezeichner setzen
• Tastenkombination Strg + B
Über die Tastenkombination Strg + F12 oder mit dem Menübefehl
Navigate > File Structure
erhält man einen Dialog mit der Struktur der aktuell im Editor bearbeiteten Klasse und kann Be-
standteile per Mausklick ansteuern:
Abschnitt 2.4 Java-Entwicklung mit IntelliJ IDEA 61

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.

2.4.3.6 Sonstige Hinweise


Im IntelliJ-Quellcodeeditor lassen sich die letzten Änderungen zurücknehmen mit der von vielen
Programmen gewohnten Tastenkombination Strg + Z. Soll eine zurückgenommenen Änderung
wiederhergestellt werden, ist offiziell die folgende Tastenkombination zu verwenden:
Strg + Umschalt + Z
In dieser Situation sollte nicht die (z. B. aus Microsoft Office) gewohnte Tastenkombination Strg +
Y verwendet werden, weil IntelliJ daraufhin die aktuelle Zeile löscht.

2.4.4 Übersetzen und Ausführen


Nun soll die im Editorfenster angezeigte Startklasse unseres Beispielprogramms übersetzt und aus-
geführt werden. Genau genommen entscheidet die eingestellte Configuration über die zu verwen-
dende Startklasse. Im Beispiel sollte die aktive Konfiguration den Namen Main haben und die
gleichnamige Startklasse verwenden (siehe Symbolleiste über dem Editor):

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.

2.4.5 Sichern und Wiederherstellen


Bei der Arbeit mit IntelliJ muss man sich um das Sichern von Quellcode und anderen im Editor
bearbeiteten Dateien kaum Gedanken machen, weil die Entwicklungsumgebung bei jeder passenden
Gelegenheit (z. B. beim Erstellen des Projekts) automatisch sichert.
Außerdem ist ein VCS (Version Control System) integriert, das lokal arbeiten und mit Cloud-
Diensten wie GitHub kooperieren kann. Wegen der zahlreichen Funktionen ist ein eigenes Haupt-
menü namens VCS vorhanden.
Für eine Quellcodedatei sind über
VCS > VCS Operations > Local History > Show History
oder über
File > Local History > Show History
alle Zwischenstände verfügbar, z. B.:

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.

2.4.6.1 SDKs einrichten


Ein IntelliJ-Projekt benötigt ein SDK (Software Development Kit), das die Standardbibliothek, den
Compiler und die JVM zur Ausführung des Programms innerhalb der Entwicklungsumgebung fest-
legt. Wir haben im Abschnitt 2.4.2 bei der Kreation des ersten Projekts ein von IntelliJ automatisch
angelegtes SDK basierend auf dem im Abschnitt 1.2.1 installierten OpenJDK 8 gewählt. Ebenso hat
IntelliJ das gemäß Abschnitt 2.1.1 installierte OpenJDK 17 entdeckt und als SDK eingerichtet. Eine
Liste der bekannten und in Projekten wählbaren SDKs erhält man z. B. über
File > Project Structure > Platform Settings > SDKs
im folgenden Dialog, der primär dazu gedacht ist, für neue Projekte ein voreingestelltes SDK fest-
zulegen:

Ü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

Aufgrund der resultierenden Einstellung

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:

Dann wählen wir den oben angegebenen SDK-Basisordner:


66 Kapitel 2 Werkzeuge zum Entwickeln von Java-Programmen

Wir vereinbaren den SDK-Namen OpenJDK 11 und nötigenfalls den folgenden Documentation
Path
https://docs.oracle.com/en/java/javase/11/docs/api/

2.4.6.2 Struktur des aktuellen Projekts


Nach dem Menübefehl
File > Project Structure
sind im folgenden Dialog

unter Project Settings wichtige Konfigurationen möglich:


• Project name
Hier lässt sich der Projektname ändern.
• Project SDK
Hier wählt man das für die Übersetzung und Ausführung des aktuellen Projekts zu verwen-
dende SDK. Man legt also den Compiler und die JVM fest.
• Project language level
Hier legt man die vom Compiler zu unterstützende Java-Version und damit auch das Verhal-
ten der IntelliJ-Syntaxerweiterung fest. Dabei muss auf Kompatibilität geachtet werden. Es
macht z. B. keinen Sinn, das OpenJDK 8 als SDK zu verwenden und gleichzeitig das
Sprachniveau 11 zu verlangen. Es ist hingegen möglich, ein Sprachniveau unterhalb der ein-
gestellten SDK-Version zu wählen. Dann kann man sich in selbst definierten Klassen auf die
ältere Syntax beschränken, aber trotzdem Bibliotheksklassen einbinden, die mit einem höhe-
ren language level (also von einer entsprechenden Compiler-Version) übersetzt worden
sind.
Damit ein ausgeliefertes Programm auf einem Kundenrechner von der dortigen JVM ausgeführt
werden kann, dürfen alle ausgelieferten Klassen maximal das Compiler-Niveau der angetroffenen
JVM haben. Bei einer mit dem Sprachniveau 11 erstellten Anwendung scheitert der Start auf einem
Kundenrechner mit der JVM 8 verständlicherweise mit einer Fehlermeldung:
Exception in thread "main" java.lang.UnsupportedClassVersionError: Bruchaddition
has been compiled by a more recent version of the Java Runtime
Abschnitt 2.4 Java-Entwicklung mit IntelliJ IDEA 67

2.4.6.3 Einstellungen für IntelliJ oder das aktuelle Projekt


Über den Menübefehl
File > Settings
oder die Tastenkombination Strg + Alt + S erreicht man den Settings-Dialog:

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:

Allerdings werden dabei nur die Einstellungen unter


...\Appdata\Roaming\JetBrains
berücksichtigt, die Einstellungen unter
...\Appdata\Local\JetBrains
hingegen nicht. Wird z. B. ...
• ein mit Hilfe der Vorlage Command Line App erstelltes IntelliJ-Projekt geschlossen,
• IntelliJ beendet, der Projektordner gelöscht und IntelliJ neu gestartet,
• ein neues Projekt mit dem alten Namen am alten Ort unter Verwendung der Vorlage Com-
mand Line App erstellt,
dann resultiert eine defekte Projekt-Konfiguration, z. B.:

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.

2.4.6.4 Einstellungen für neue Projekte


Über
File > New Projects Setup > Settings for New Projects
legt man die Einstellungen für neue Projekte fest. Man kann z. B. im Abschnitt
Editor > Code Style > Java
dafür sorgen, dass im Editor für Java-Quellcode das Tabulatorzeichen nicht durch mehrere Leerzei-
chen ersetzt wird:

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.

2.4.7 Übungsprojekte zum Kurs verwenden


Die im Kurs angebotenen IntelliJ-Übungsprojekte lassen sich auf Ihrem Rechner mit der dortigen
IntelliJ-Installation aus den kopierten Projektordnern öffnen, wenn auf Ihrem Rechner ...
• das OpenJDK 8 (zum Bezug und zur Installation siehe Abschnitt 1.2.1), das OpenJDK 11
(siehe Abschnitt 2.4.6.1) sowie das OpenJDK 17 (zum Bezug und zur Installation siehe Ab-
schnitt 2.1.1) installiert sind,
• und in IntelliJ für diese SDKs die Namen OpenJDK 8, OpenJDK 11 sowie OpenJDK 13
vereinbart sind.
Zur Einrichtung von SDKs in IntelliJ siehe Abschnitt 2.4.6.1.
Einige Übungsprojekte verwenden die selbst erstellte, nicht zum Java-API gehörige Klasse Simput
zur Vereinfachung der Konsoleneingabe. Im Abschnitt 3.4.2 ist zu erfahren, wie die Java-
Archivdatei Simput.jar mit der Klasse Simput als IntelliJ-globale Bibliothek eingerichtet wird,
sodass die Klasse Simput in einem Projekt durch das Einbinden dieser Bibliothek bequem zu nut-
zen ist.

2.5 OpenJFX und Scene Builder installieren


Wir wollen bei der Entwicklung von Programmen mit grafischer Bedienoberfläche die JavaFX-
Bibliothek einsetzen. Seit Java 11 ist diese Bibliothek kein JDK-Bestandteil mehr, wird aber unter
dem Namen OpenJFX als Open Source (unter derselben Lizenz wie das OpenJDK) aktiv weiter-
entwickelt, wobei sich besonders die Firma Gluon engagiert.1 Hier
https://gluonhq.com/products/javafx/
steht am 20.10.2021 die OpenJFX-Version 17.0.1 zur Verfügung. Wir beziehen die Variante Ja-
vaFX Windows SDK in der Datei openjfx-17.0.1_windows-x64_bin-sdk.zip und packen diese im
folgenden Ordner aus:
C:\Program Files\Java\OpenJFX-SDK-17

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:

Die Installation belegt ca. 120 MB ist flott erledigt:


Abschnitt 2.5 OpenJFX und Scene Builder installieren 73

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.6 Übungsaufgaben zum Kapitel 2


1) Führen Sie nach Möglichkeit auf Ihrem eigenen PC die in den Abschnitten 1.2.1 (OpenJDK 8),
2.1 (OpenJDK 17) und 2.3 (IntelliJ 2021.2.2, inkl. OpenJDK 11) beschriebenen Installationen aus.
Richten Sie in IntelliJ basierend auf den installierten OpenJDK-Versionen jeweils ein SDK ein (sie-
he Abschnitt 2.4.6.1).
OpenJFX (im OpenJDK 8 aus dem ojdkbuild-Projekt enthalten, im OpenJDK 11 und im OpenJDK
17 aber nicht) sowie den Scene Builder benötigen wir erst zur Erstellung von Programmen mit gra-
fischer Bedienoberfläche. Auf die im Abschnitt 2.5 beschriebenen Installationen können Sie also
vorläufig verzichten.

2) Experimentieren Sie mit dem Hallo-Beispielprogramm aus dem Abschnitt 2.2.1, z. B. indem Sie
weitere Ausgabeanweisungen ergänzen.

3) Beseitigen Sie die Fehler in der folgenden Variante des Hallo-Programms:


class Hallo {
static void mein(String[] args) {
System.out.println("Hallo allerseits!);
}
Abschnitt 2.6 Übungsaufgaben zum Kapitel 2 75

4) Welche der folgenden Aussagen sind richtig bzw. falsch?


1. Beim Übersetzen einer Java-Quellcodedatei mit dem OpenJDK-Compiler javac.exe muss
man den Dateinamen samt Erweiterung (.java) angeben.
2. Beim Starten eines Java-Programms muss man den Namen der auszuführenden Klasse samt
Extension (.class) angeben.
3. Damit der Aufruf des OpenJDK-Compilers javac.exe (ohne Pfadangabe) von jedem Ver-
zeichnis aus klappt, muss unter Windows das bin-Unterverzeichnis der OpenJDK-
Installation vorrangig in die Definition der Umgebungsvariablen PATH aufgenommen wer-
den.
4. Die main() - Methode der Startklasse eines Java-Programms muss einen Parameter mit dem
Datentyp String[] und dem Namen args besitzen, damit sie von der JVM erkannt wird.

5) Kopieren Sie die Bytecode-Datei


…\BspUeb\Simput\Standardpaket\Simput.class
mit der Klasse Simput auf Ihren PC, und tragen Sie das Zielverzeichnis in den CLASSPATH ein
(siehe Abschnitt 2.2.4). Testen Sie den Zugriff auf die class-Datei z. B. mit der Konsolenvariante
des Bruchadditionsprogramms (siehe Abschnitt 1.2.2).
Alternativ können Sie auch die Java-Archivdatei
…\BspUeb\Simput\Standardpaket\Simput.jar
auf Ihren PC kopieren und in den Klassenpfad aufnehmen. Mit Java-Archivdateien werden wir uns
noch ausführlich beschäftigen.
3 Elementare Sprachelemente
Im Kapitel 1 wurde anhand eines halbwegs realistischen Beispiels ein erster Eindruck von der ob-
jektorientierten Software-Entwicklung mit Java vermittelt. Nun erarbeiten wir uns die Details der
Programmiersprache Java und beginnen dabei mit elementaren Sprachelementen. Diese dienen zur
Realisation von Algorithmen innerhalb von Methoden und sehen bei Java nicht wesentlich anders
aus als bei älteren, nicht objektorientierten Sprachen (z. B. C).

3.1 Einstieg

3.1.1 Aufbau eines Java-Programms


Zunächst soll unser bisheriges Wissen über die Struktur von Java-Programmen zusammengefasst
werden:
• Ein Java-Programm besteht aus Klassen. Für das Bruchrechnungsbeispiel im Abschnitt 1.1
wurden die Klassen Bruch und Bruchaddition definiert. In den Methoden der beiden
Klassen kommen weitere Klassen zum Einsatz:
o Klassen aus der Standardbibliothek (z. B. System, Math)
o Die zur Erleichterung von Benutzereingaben in Konsolenprogrammen selbst erstellte
Klasse Simput
Meist verwendet man für den Quellcode einer Klasse jeweils eine eigene Textdatei mit der
Namenserweiterung .java.1 Der Compiler erzeugt grundsätzlich für jede Klasse eine eigene
Bytecode-Datei mit der Namenserweiterung .class.
• Eine Klassendefinition besteht aus …
o dem Kopf
Er enthält nach dem Schlüsselwort class den Namen der Klasse. Soll eine Klasse für
beliebige andere Klassen (aus fremden Paketen, siehe Kapitel 6) nutzbar sein, dann
muss dem Schlüsselwort class der Zugriffsmodifikator public vorangestellt werden,
z. B.:
public class Bruch {
. . .
}
o und dem Rumpf
Begrenzt durch ein Paar geschweifter Klammern befinden sich hier …
▪ die Deklarationen der Instanz- und Klassenvariablen (Eigenschaften)
▪ und die Definitionen der Methoden (Handlungskompetenzen).
• Auch eine Methodendefinition besteht aus …
o dem Kopf
Hier werden vereinbart: Modifikatoren, Rückgabetyp, Name der Methode, Parame-
terliste. All diese Bestandteile werden noch ausführlich erläutert.

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

o und dem Rumpf


Begrenzt durch ein Paar geschweifte Klammern befinden sich hier Anweisungen,
mit denen zur Realisation von Algorithmen z. B. Instanzvariablen das agierenden
Objekts verändert werden, wobei lokale Variablen zum Speichern von Zwischener-
gebnissen zum Einsatz kommen. Der Unterschied zwischen Instanzvariablen (Eigen-
schaften von Objekten), statischen Variablen (Eigenschaften von Klassen) und loka-
len Variablen von Methoden wird im Abschnitt 3.3 erläutert.
• Eine Anweisung ist die kleinste ausführbare Einheit eines Programms.
In Java sind bis auf wenige Ausnahmen alle Anweisungen mit einem Semikolon abzu-
schließen.
• Von den Klassen eines Programms muss eine startfähig sein.
Dazu benötigt sie eine Methode mit dem Namen main(), dem Rückgabetyp void, einer be-
stimmten Parameterliste (String[] args) sowie den Modifikatoren public und static. Im
Bruchrechnungsbeispiel im Abschnitt 1.1 ist die Klasse Bruchaddition startfähig.

3.1.2 Projektrahmen zum Üben von elementaren Sprachelementen


Während der Beschäftigung mit elementaren Java-Sprachelementen werden wir der Einfachheit
halber mit einer relativ untypischen, jedenfalls nicht sonderlich objektorientierten Programmstruk-
tur arbeiten, die Sie schon aus dem Hallo-Beispiel kennen (siehe Abschnitt 2.2.1). Es wird nur eine
Klasse definiert, und diese enthält nur eine einzige Methodendefinition. Weil die Klasse startfähig
sein muss, liegt der einzige Methodenkopf nach den im letzten Abschnitt wiederholten Regeln fest.
Weil die Klasse nicht für andere Klassen ansprechbar sein soll, ist der Zugriffsmodifikator public
für die Klasse überflüssig, und wir erhalten die folgende Programmstruktur:
class Prog {
public static void main(String[] args) {
//Platz für elementare Sprachelemente
}
}
Damit die pseudo-objektorientierten (POO-) Programme Ihren Programmierstil nicht prägen, wurde
an den Beginn des Manuskripts ein Beispiel gestellt (Bruchrechnung), das bereits etliche OOP-Prin-
zipien realisiert.
Für die meist kurzzeitige Beschäftigung mit bestimmten elementaren Sprachelementen lohnt sich
selten ein spezielles IntelliJ-Projekt. Legen Sie daher für solche Zwecke mit dem Menübefehl
File > New > Project
analog zu Abschnitt 2.4.2 ...
• ein Java-Projekt
• basierend auf dem OpenJDK 8
• unter Verwendung des Templates Command Line App
• mit dem Namen Prog
• ohne Base package
an. Den überflüssigen class-Modifikator public im automatisch erstellten Klassendefinitionskopf
können Sie löschen oder belassen.
Ändern Sie mit der im Abschnitt 2.4.3.5 beschriebenen Refaktorierung den Namen der vordefinier-
ten Klasse von Main in Prog, z. B. so:
Abschnitt 3.1 Einstieg 79

• Einfügemarke im Editor auf den alten Namen setzen


• Tastenkombination Umschalt + F6
• Neuen Namen eintragen und mit Enter quittieren
Wie die Beschriftung des Editorfensters zeigt, ist beim Refaktorieren ist auch der Name der Quell-
codedatei geändert worden. Weil die Quellcodedatei den empfohlenen Namen Prog.java trägt, wird
im src-Knoten des Project-Fensters nur der Klassenname angezeigt:

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

public class Bruch {

Feld- private int zaehler;


deklarationen private int nenner = 1;

public void setzeZaehler(int z) {


zaehler = z;
}

public boolean setzeNenner(int n) {


if (n != 0) {
nenner = n;
return true;
} else
return false;
}

public int gibZaehler() {return zaehler;}

Methoden- public int gibNenner() {return nenner;}


definitionen public void kuerze() {
. . .
}

public void addiere(Bruch b) {


. . .
}

public void frage() {


. . .
}

public void zeige() {


. . .
}

}
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

Rückgabetyp Name Parameter-


( )
deklaration
Modifikator
,

Methodenrumpf

{ }

Anweisung

Als Beispiel betrachten wir die Definition der Bruch-Methode addiere():


Rückgabe-
Modifikator Name Parameterdeklaration
typ

public void addiere(Bruch b) {


zaehler = zaehler * b.nenner + b.zaehler * nenner;
Anweisungen nenner = nenner * b.nenner;
kuerze();
}

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.

3.1.4 Hinweise zur Gestaltung des Quellcodes


Zur Formatierung von Java - Programmen haben sich Konventionen entwickelt, die wir bei passen-
der Gelegenheit besprechen werden. Der Compiler ist hinsichtlich der Formatierung des Quellcodes
sehr tolerant und beschränkt sich auf folgende Regeln:
• Die einzelnen Bestandteile einer Definition oder Anweisung müssen in der richtigen Rei-
henfolge stehen.
• Zwischen zwei Sprachbestandteilen muss im Prinzip ein Trennzeichen stehen, wobei das
Leerzeichen, das Tabulatorzeichen und der Zeilenumbruch erlaubt sind. Diese Trennzeichen
dürfen sogar in beliebigen Anzahlen und Kombinationen auftreten. Innerhalb eines Sprach-
bestandteils (z. B. Namens) sind Trennzeichen (z. B. Zeilenumbruch) natürlich verboten.
• Zeichen mit festgelegter Bedeutung wie z. B. "{", ";", "(", "+", ">" sind selbstisolierend, d .h.
davor und danach sind keine Trennzeichen nötig (aber erlaubt).
Manche Programmierer setzen die öffnende geschweifte Klammer zum Rumpf einer Klassen- oder
Methodendefinition ans Ende der Kopfzeile (siehe linkes Beispiel), andere bevorzugen den Anfang
der Folgezeile (siehe rechtes Beispiel):
class Hallo { class Hallo
public static void main(String[] par) { {
System.out.print("Hallo"); public static void main(String[] par)
} {
} System.out.print("Hallo");
}
}

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

3.1.7 Vollständige Klassennamen und Import-Deklaration


Jede Java-Klasse gehört zu einem Paket (siehe Kapitel 6), und dem Namen der Klasse ist grund-
sätzlich der Paketname voranzustellen. Dies gilt natürlich auch für die API-Klassen, also z. B. für
die im folgenden Beispielprogramm verwendete Klasse Random aus dem Paket java.util.1 Objekte
dieser Klasse beherrschen u. a. die Methode nextInt(), die eine Pseudozufallszahl mit dem Daten-
typ int liefert, z. B.:
Quellcode Ausgabe
class Prog { 1053985008
public static void main(String[] args) {
java.util.Random zuf = new java.util.Random();
System.out.println(zuf.nextInt());
}
}

Keine Mühe mit dem Paketnamen hat man bei …


• den Klassen im Paket der aktuellen Quellcodedatei
Eine Quellcodedatei kann per package-Deklaration einem Paket zugeordnet werden, sodass
alle in der Datei definierten Klassen zu diesem Paket gehören (siehe Abschnitt 6.1.1). Die in
einer Quellcodedatei ohne package-Deklaration definierten Klassen gehören zum Stan-
dardpaket.
• den Klassen aus dem API-Paket java.lang (z. B. Math)
Die Klassen dieses Pakets werden automatisch in jede Quellcodedatei importiert (siehe un-
ten).
Um in einer Quellcodedatei bei Klassen aus anderen (API-)Paketen die lästige Angabe von Paket-
namen zu vermeiden, kann man einzelne Klassen und/oder komplette Pakete importieren. Die zu-
ständigen import-Deklarationen sind an den Anfang der Quellcodedatei zu setzen, z. B. zum Im-
portieren der Klasse java.util.Random:
Quellcode Ausgabe
import java.util.Random; 1053985008
class Prog {
public static void main(String[] args) {
Random zuf = new Random();
System.out.println(zuf.nextInt());
}
}

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 . ;
*

In vergleichbaren Fällen werden wir zukünftig auf ein Syntaxdiagramm verzichten.


Mit der Anzahl importierter Bezeichner steigt das Risiko für eine Namenskollision. Die Entwick-
lungsumgebung und der Compiler meckern, ...
• wenn zwei namensgleiche Klassen aus verschiedenen Paketen explizit importiert werden,
• wenn eine in der Quellcodedatei definierte und eine explizit importierte Klasse denselben
Namen besitzen.
Wenn aufgrund der Platzhaltersyntax aus mehreren Paketen namensgleiche Klassen importiert wor-
den sind, dann muss bei der Verwendung einer Klasse durch Voranstellen des Paketnamens für
Eindeutigkeit gesorgt werden.
Das Importieren von Klassen bzw. kompletten Paketen kann nur dann zum gewünschten Ergebnis
führen, wenn die zugehörigen Bytecode-Dateien von der Entwicklungsumgebung bzw. vom Compi-
ler (beim Übersetzen des Quellcodes) und von der JVM (bei der Ausführung des Programms) ge-
funden werden. Bei den Klassen aus dem Java-API ist dies garantiert. Damit das Importieren ande-
rer Klassen klappt, müssen die Entwicklungsumgebung bzw. der Compiler und die Runtime darüber
informiert werden, an welchen Orten gesucht werden soll (siehe Abschnitte 2.2.4 bzw. 3.4.2).

3.2 Ausgabe bei Konsolenanwendungen


In diesem Abschnitt beschäftigen wir uns mit der Ausgabe von Zeichen in einem Konsolenfenster.
Eine einfache Möglichkeit zur Konsoleneingabe wird im Abschnitt 3.4 vorgestellt.

3.2.1 Ausgabe einer (zusammengesetzten) Zeichenfolge


Um eine einfache Konsolenausgabe in Java zu bewerkstelligen, bittet man das Objekt System.out
(aus der Klasse PrintStream) seine print() - oder seine println() - Methode auszuführen.1 Im Un-
terschied zu print() schließt println() die Ausgabe mit einem Zeilenwechsel ab, sodass die nächs-
ten Aus- oder Eingabe in einer neuen Zeile erfolgt. Folglich ist print() zu bevorzugen, …
• wenn eine Benutzereingabe unmittelbar hinter einer Ausgabe in derselben Zeile ermöglicht
werden soll,
• wenn die von mehreren Methodenaufrufen veranlassten Ausgaben in einer Zeile erscheinen
sollen.
Beide Methoden erwarten ein einziges Argument, wobei erlaubt sind:

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

• eine Zeichenfolge, in durch doppelte Anführungszeichen zu begrenzen ist


Beispiel: System.out.print("Hallo allerseits!");
• ein sonstiger Ausdruck (siehe Abschnitt 3.5)
Dessen Wert wird automatisch in eine Zeichenfolge gewandelt.
Beispiele: - System.out.println(ivar);
Hier wird der Wert der Variablen ivar ausgegeben.
- System.out.println(i==13);
An die Möglichkeit, als print() - bzw. println() - Parameter nahezu beliebige
Ausdrücke anzugeben, müssen sich Einsteiger erst gewöhnen. Im Beispiel wird
der Wert eines Vergleichs (der Variablen i mit der Zahl 13) ausgegeben. Bei
Identität erscheint auf der Konsole das Wort true, ansonsten das Wort false.
Besonders angenehm ist die Möglichkeit, mehrere Teilausgaben mit dem Plusoperator zu verketten,
z. B.:
System.out.println("Ergebnis: " + netto*MWST);
Im Beispiel wird der numerische Wert von netto*MWST (Produkt aus zwei Variablen) in eine Zei-
chenfolge gewandelt und dann mit "Ergebnis: " verknüpft.

3.2.2 Formatierte Ausgabe


Gelegentlich sind bei einer Konsolenausgabe die Gestaltungsmöglichkeiten der PrintStream-
Methoden print() und println() unzureichend, weil sich z. B. die Anzahl der bei einer Zahl ausge-
gebenen Dezimalstellen nicht beeinflussen lässt. Dann bietet sich die PrintStream-Methode
printf() an, die eine formatierte Ausgabe von mehreren Ausdrücken erlaubt.1 Weil System.out ein
Objekt der Klasse PrintStream ist, beherrscht es auch die Methode printf(), z. B.:
Quellcode Ausgabe
class Prog { Pi = 3,142
public static void main(String[] args) { Pi = 3,1415927, e = 2,7182818
System.out.printf("Pi = %9.3f%n", Math.PI);
System.out.printf("Pi = %9.7f, e = %9.7f",
Math.PI, Math.E);
}
}

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

% Nummer $ Optionen Breite . Präzision Format

Darin bedeuten:

1
Alternativ kann die äquivalente PrintStream-Methode format() benutzt werden.
Abschnitt 3.2 Ausgabe bei Konsolenanwendungen 93

Nummer Nummer des auszugebenden Arguments (mit 1 beginnend)


Die Angabe einer Nummer ist z. B. dann von Nutzen, wenn ein Ar-
gument mehrfach ausgegeben werden soll. Den nicht über ihre Num-
mer angesprochenen Argumenten werden sukzessive die Formatie-
rungsangaben ohne Nummern zugordnet, solange der Vorrat reicht.
Unversorgte Argumente werden nicht ausgegeben.
Optionen Formatierungsoptionen, u. a. sind erlaubt:
- bewirkt eine linksbündige Ausgabe statt der voreingestellten
rechtsbündigen Ausgabe
, ist nur für Zahlen erlaubt und bewirkt eine Zifferngruppierung
(z. B. Ausgabe von 12.123,33 statt 12123,33)
Breite Ausgabebreite für das zugehörige Argument
Präzision Anzahl der Nachkommastellen oder sonstige Präzisionsangabe
(abhängig vom Format)
Format Formatspezifikation gemäß anschließender Tabelle
Es werden u. a. die folgenden Formate unterstützt:
Beispiele
Format Beschreibung
printf() - Parameterliste Ausgabe
d ganze Zahl ("%7d", 4711) 4711
("%-7d", 4711) 4711
("%1$d %1$,d", 4711) 4711 4.711

f Kommazahl mit einer festen Anzahl von ("%5.2f", 4.711) 4,71


Nachkommastellen
Präzision: Anzahl der Nachkommastellen
(Voreinstellung: 6)
e Kommazahl in wissenschaftlicher ("%e", 47.11) 4,711000e+01
Notation ("%.2e", 47.11) 4,71e+01
("%12.2e", 47.11) 4.71e+01
Präzision: Anzahl Stellen in der Mantisse
(Voreinstellung: 6)
c ein einzelnes Zeichen // x ist eine char- Inhalt von x: h
// Variable
("Inhalt von x: %c", x)

n plattformspezifische Zeilentrennung ("Inhalt von x:%n%c",x) Inhalt von x:


h

s eine Zeichenfolge // str ist eine String- Text: abc


// Variable
("Text: %-7s", str)

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

3.3 Variablen und Datentypen


Während ein Programm läuft, müssen zahlreiche Daten im Arbeitsspeicher des Rechners abgelegt
werden und anschließend mehr oder weniger lange für lesende und schreibende Zugriffe verfügbar
sein, z. B.:
• Die Eigenschaftsausprägungen eines Objekts werden aufbewahrt, solange das Objekt exis-
tiert.
• Die in einer Methode benötigten Daten werden bis zum Ende der Methodenausführung ge-
speichert.
Zum Speichern eines Werts (z. B. einer ganzen Zahl) wird eine sogenannte Variable verwendet,
worunter Sie sich einen benannten Speicherplatz für einen Wert mit einem bestimmten Daten-
typ (z. B. Ganzzahl) vorstellen können.
Eine Variable erlaubt (bei bestehender Zugriffsberechtigung) über ihren Namen den lesenden
und/oder schreibenden Zugriff auf die zugehörige Stelle im Arbeitsspeicher, z. B.:

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
}
}

3.3.1 Strenge Compiler-Überwachung bei Java-Variablen


Um die Details bei der Verwaltung der Variablen im Arbeitsspeicher müssen wir uns nicht küm-
mern. Allerdings verlangt Java beim Umgang mit Variablen im Vergleich zu anderen Programmier-
oder Skriptsprachen einige Sorgfalt, letztlich mit dem Ziel, Fehler zu vermeiden:
• Variablen müssen explizit deklariert werden, z. B.:
int ivar = 13;
Wenn Sie versuchen, eine nicht deklarierte Variable zu verwenden, wird beim Überset-
zungsversuch ein Fehler gemeldet, z. B. vom Compiler javac.exe aus dem OpenJDK 17:

Unsere Entwicklungsumgebung IntelliJ erkennt und dokumentiert das Problem unmittelbar


nach der Eingabe im Editor. Wenn sich der Mauszeiger im Umfeld des Fehlers befindet, er-
scheint eine Erläuterung:

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:

Durch den Deklarationszwang werden z. B. Programmfehler wegen falsch geschriebener


Variablennamen verhindert. Würde der Java-Compiler den folgenden Quellcode übersetzen,
dann käme es in einem realen Programm irgendwann zu einem Logikfehler, weil die Variab-
le ivar nicht den erwarteten Wert hätte:

• Java ist streng und statisch typisiert.1


Für jede Variable ist bei der Deklaration ein fester (später nicht mehr änderbarer) Datentyp
anzugeben. Er legt fest, …
o welche Informationen (z. B. ganze Zahlen, Zeichen, Adressen von Bruch-Objekten)
in der Variablen gespeichert werden können,
o welche Operationen auf die Variable angewendet werden dürfen.
Der Compiler kennt zu jeder Variablen den Datentyp und kann daher Typsicherheit garan-
tieren, d. h. die Zuweisung von Werten mit ungeeignetem Datentyp verhindern. Außerdem
kann auf (zeitaufwändige) Typprüfungen zur Laufzeit verzichtet werden. In der folgenden
Anweisung
int ivar = 4711;

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 beginnen mit einem Kleinbuchstaben.


• Besteht ein Name aus mehreren Wörtern (z. B. numberOfObjects), dann schreibt man ab
dem zweiten Wort die Anfangsbuchstaben groß (Camel Casing). Das zur Vermeidung von
Urheberrechtsproblemen handgemalte Tier kann hoffentlich trotz ästhetischer Mängel zur
Begriffsklärung beitragen:

• Variablennamen mit einem einzigen Buchstaben sollten nur in speziellen Fällen verwendet
werden (z. B. als Laufvariable von Wiederholungsanweisungen, siehe unten).

3.3.3 Primitive Datentypen und Referenztypen


In der objektorientierten Programmierung werden neben den traditionellen (elementaren, primiti-
ven) Variablen zur Aufbewahrung von Zahlen, Zeichen oder Wahrheitswerten auch Variablen be-
nötigt, die die Adresse eines Objekts aufnehmen und so die Kommunikation mit dem Objekt er-
möglichen. Wir unterscheiden also in Java bei den Datentypen von Variablen zwei übergeordnete
Kategorien:
• Primitive Datentypen
Die Variablen mit einem primitiven Datentyp sind auch in Java unverzichtbar (z. B. als Fel-
der von Klassen oder als lokale Variablen in Methoden), obwohl sie „nur“ zur Verwaltung
ihres Inhalts dienen und keine Rolle bei der Kommunikation mit Objekten spielen.
In der Bruch-Klassendefinition (siehe Abschnitt 1.1.2) haben die Felder für Zähler und
Nenner eines Objekts den primitiven Typ int, können also eine Ganzzahl im Bereich
von -2147483648 bis 2147483647 (-231 bis 231 - 1) aufnehmen. Diese Felder werden in den
folgenden Anweisungen deklariert, wobei das Feld nenner auch noch einen expliziten Ini-
tialisierungswert erhält:
private int zaehler;
private int nenner = 1;
Beim Feld zaehler wird auf die explizite Initialisierung verzichtet, sodass die automati-
sche Null-Initialisierung von Feldern greift. Für ein frisch erzeugtes Bruch-Objekt befinden
sich also im Arbeitsspeicher die folgenden Instanzvariablen (Felder):
zaehler nenner

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

Bruch@1960f05 zaehler nenner

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.

3.3.4 Klassifikation der Variablen nach Zuordnung


In Java unterscheiden sich Variablen nicht nur hinsichtlich des Datentyps (Inhalts), sondern auch
hinsichtlich der Zuordnung zu einer Methode, zu einem Objekt oder zu einer Klasse:
• Lokale Variablen
Sie werden innerhalb einer Methode deklariert. Ihre Gültigkeit (Verwendbarkeit) beschränkt
sich auf die Methode bzw. auf einen Anweisungsblock (siehe Abschnitt 3.3.9) innerhalb der
Methode.
Solange eine Methode ausgeführt wird, befinden sich ihre Variablen in einem Bereich des
programmeigenen Arbeitsspeichers, den man als Stack (deutsch: Stapel) bezeichnet.
• Instanzvariablen (nicht-statische Felder)
Instanzvariablen werden außerhalb jeder Methode deklariert. Jedes Objekt (synonym: jede
Instanz) einer Klasse verfügt über einen vollständigen Satz der Instanzvariablen der Klasse.
So besitzt z. B. jedes Objekt der Klasse Bruch einen zaehler und einen nenner.
Solange ein Objekt existiert, befinden es sich mit all seinen Instanzvariablen in einem Be-
reich des programmeigenen Arbeitsspeichers, den man als Heap (deutsch: Haufen) bezeich-
net.
• Klassenvariablen (statische Felder)
Klassenvariablen werden außerhalb jeder Methode deklariert und erhalten dabei den Modi-
fikator static. Diese Variablen beziehen sich auf eine Klasse insgesamt, nicht auf einzelne
Instanzen der Klasse. Z. B. kann man in einer Klassenvariablen festhalten, wie viele Objekte
der Klasse bereits bei einem Programmeinsatz erzeugt worden sind. In unserem Bruchrech-
nungsbeispiel haben wir der Einfachheit halber bisher auf statische Felder verzichtet. Aller-
dings sind uns schon statische Felder aus anderen Klassen begegnet:
o Aus der Klasse System kennen wir das statische Feld out. Es zeigt auf ein Objekt
der Klasse PrintStream, das wir häufig mit Konsolenausgaben beauftragen.
o In einem Beispielprogramm im Abschnitt 3.2.2 über die formatierte Ausgabe haben
wir die trigonometrische Konstante  ( 3.1416) aus dem statischen Feld PI der
Klasse Math gelesen.1
Während jedes Objekt einer Klasse über einen eigenen Satz mit allen Instanzvariablen ver-
fügt, die beim Erzeugen des Objekts auf dem Heap angelegt werden, existieren Klassenvari-
ablen nur einmal. Sie werden beim Laden der Klasse in der sogenannten Method Area des
Arbeitsspeichers abgelegt.
Die im Wesentlichen schon aus dem Abschnitt 3.3.3 bekannte Abbildung zur Lage im Arbeitsspei-
cher bei Ausführung der main() - Methode der Klasse Bruchaddition aus unserem OOP-
Standardbeispiel (vgl. Abschnitt 1.1) wird anschließend ein wenig präzisiert. Durch Farben und
Ortsangaben wird für die beteiligten lokalen Variablen bzw. Instanzvariablen die Zuordnung zu
einer Methode bzw. zu einem Objekt und die damit verbundene Speicherablage verdeutlicht:

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

lokale Variablen der Bruchaddition-


Bruch-Objekt

Methode main() (Datentyp: Bruch)


zaehler nenner
b1

Bruch@87a5cc 0 1

b2 Bruch-Objekt

Bruch@1960f05 zaehler nenner

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.

3.3.5 Eigenschaften einer Variablen


Als Eigenschaften einer Java-Variablen haben Sie nun kennengelernt:
• Zuordnung
Eine Variable gehört entweder zu einer Methode, zu einem Objekt oder zu einer Klasse. Da-
raus resultiert ihr Ablageort im Arbeitsspeicher. Als wichtige Speicherregionen unter-
scheiden wir Stack, Heap und Method Area. Dieses Hintergrundwissen hilft z. B., wenn ein
StackOverflowError gemeldet wird.
• Datentyp
Damit sind festgelegt: Zulässige Werte (hinsichtlich Typ und Größe), Speicherplatzbedarf
und zulässige Operationen. Besonders wichtig ist die Unterscheidung zwischen den primiti-
ven Datentypen und den Referenztypen.
• Name
Es sind beliebige Bezeichner gemäß Abschnitt 3.1.6 erlaubt, wobei die Empfehlungen aus
dem Abschnitt 3.3.2 beachtet werden sollten.
102 Kapitel 3 Elementare Sprachelemente

• 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;
}
}

3.3.6 Primitive Datentypen in Java


Als primitiv bezeichnet man in Java die (auch in älteren Programmiersprachen bekannten) Datenty-
pen zur Aufnahme von einzelnen Zahlen, Zeichen oder Wahrheitswerten. Speziell für Zahlen exis-
tieren diverse Datentypen, die sich hinsichtlich Speichertechnik, Wertebereich und Platzbedarf un-
terscheiden. Von der folgenden Tabelle sollte man sich vor allem merken, wo sie zu finden ist,
wenn für eine konkrete Aufgabe ein möglichst sparsamer Datentyp gefragt ist, der alle zu erwarten-
den Werte aufnehmen kann. Eventuell sind Sie aber auch jetzt schon neugierig auf einige Details:

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,40282351038
IEEE-754 mit einer Genauigkeit von Maximum: 1 für das Vorz.,
8 für den Expon.,
mindestens 7 Dezimalstellen in der 3,40282351038 23 für die Mantisse
Mantisse. Kleinster positiver Betrag:
Beispiel: 1.4012984610-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,797693134862315710308
IEEE-754 (64 Bit) mit einer Genauig- Maximum: 1 für das Vorz.,
11 für den Expon.,
keit von mindestens 15 signifikanten 1,797693134862315710308 52 für die Mantisse
Dezimalstellen in der Mantisse. Kleinster positiver Betrag:
Beispiel: 4,940656458412465410-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.

3.3.7 Darstellung von Gleitkommazahlen im Arbeitsspeicher


Dieser Abschnitt kann beim ersten Lesen des Manuskripts übersprungen werden. Er enthält wichti-
ge Details zu binären Gleitkommatypen, ist also relevant für Software, die solche Typen in wesent-
lichem Umfang verwendet (z. B. für mathematische oder naturwissenschaftliche Aufgaben).

3.3.7.1 Binäre Gleitkommadarstellung


Bei den binären Gleitkommatypen float und double werden auch „relativ glatte“ Zahlen im Allge-
meinen nur approximativ gespeichert, wie das folgende Programm zeigt:
Quellcode Ausgabe
class Prog { 1,3000000
public static void main(String[] args) { 1,29999995
float f130 = 1.3f; 1,250000000000000000
float f125 = 1.25f;
System.out.printf("%9.7f", f130);
System.out.println();
System.out.printf("%10.8f", f130);
System.out.println();
System.out.printf("%20.18f", f125);
}
}

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,4012984610-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,17510-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,87747210-39  (-1)1  2-126  2-1 1 00000000 10000000000000000000000
1,40129810-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.

3.3.7.2 Dezimale Gleitkommadarstellung


Wenn die Speicher- und Rechengenauigkeit der binären Gleitkommatypen für eine Anwendung
nicht reicht, dann kommt in Java die Klasse BigDecimal aus dem Paket java.math in Frage. Objek-
te dieser Klasse können Dezimalzahlen mit fast beliebiger Genauigkeit speichern und verwenden
eine dezimale Gleitkommaarithmetik.

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.

3.3.8 Variablendeklaration, Initialisierung und Wertzuweisung


In einem Java-Programm muss jede Variable vor ihrer ersten Verwendung deklariert werden, wobei
auf jeden Fall ein Datentyp und ein Name anzugeben sind. Wir betrachten vorläufig nur lokale Va-
riablen, die innerhalb einer Methode existieren. Ihre Deklaration darf im Methodenquellcode an
beliebiger Stelle vor der ersten Verwendung erscheinen. Um den (im Abschnitt 3.3.9 behandelten)
Gültigkeitsbereich einer lokalen Variablen zur Vermeidung von Fehlern zu minimieren, sollte sie
unmittelbar vor der ersten Verwendung deklariert werden (Bloch 2018, S. 261).
Es folgt das Syntaxdiagramm zur Deklaration einer lokalen Variablen, wobei zunächst der Über-
sichtlichkeit halber die mit Java 10 eingeführte Typinferenz (siehe unten) ignoriert wird:
Deklaration einer lokalen Variablen

Datentyp Variablenname = Ausdruck ;

Als Datentypen kommen in Frage (vgl. Abschnitt 3.3.3):


• Primitive Datentypen, z. B.
int wasser;
• Referenztypen, also Klassen (aus dem Java-API oder selbst definiert), z. B.
Bruch b1;
Neu deklarierte lokale Variablen kann man optional gleich initialisieren, also auf einen gewünsch-
ten Wert bringen, z. B.:
int wasser = 4711;
Bruch b1 = new Bruch();
Im zweiten Beispiel wird per new-Operator ein Bruch-Objekt erzeugt und dessen Adresse in die
Referenzvariable b1 geschrieben. Mit der Objektkreation und auch mit der Konstruktion von gülti-
Abschnitt 3.3 Variablen und Datentypen 109

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

var Variablenname = Ausdruck ;

sind die folgenden Regeln einzuhalten:


• Es kann nur eine Variable deklariert werden.
• Es muss eine Initialisierung erfolgen.
Bei komplexen Typangaben, die im Kurs bisher noch nicht aufgetaucht sind, ermöglicht das
Schlüsselwort var eine Arbeitserleichterung. Wenn sich dabei die Lesbarkeit des Quellcodes ver-
schlechtert, wird aber die Nutzung des Codes (durch andere Programmierer) erschwert.
Durch lokale Variablen werden namensgleiche Instanz- bzw. Klassenvariablen überdeckt. Diese
bleiben jedoch über ein geeignetes Präfix weiter ansprechbar:
• this bei Instanzvariablen
• Klassenname bei statischen Variablen
Weil eine solche Benennungspraxis kaum sinnvoll ist, verzichten wir auf Beispiele.
Um den Wert einer Variablen im weiteren Programmablauf zu verändern, verwendet man eine
Wertzuweisung, die zu den einfachsten Java-Anweisungen gehört:
Wertzuweisungsanweisung

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

3.3.9 Blöcke und Sichtbarkeitsbereiche für lokale Variablen


Wie Sie bereits wissen, besteht der Rumpf einer Methodendefinition aus einem Block mit beliebig
vielen Anweisungen, abgegrenzt durch geschweifte Klammern. Innerhalb des Methodenrumpfes
können untergeordnete Anweisungsblöcke gebildet werden, wiederum durch geschweifte Klammen
begrenzt:
Block- bzw. Verbundanweisung

{ 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

Prog.java:9: error: cannot find symbol


System.out.println("Gesamtwert = " + (wert1 + wert2));
^
symbol: variable wert2
location: class Prog
1 error

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

Einfügemarke des Editors neben der Startklammer

hervorgehobene Endklammer

3.3.10 Finalisierte lokale Variablen


In der Regel sollten auch die im Programm benötigten konstanten Werte (z. B. für den Mehrwert-
steuersatz) in einer Variablen abgelegt und im Quellcode über ihren Variablennamen angesprochen
werden, denn:
• Bei einer späteren Änderung des Werts ist nur die Quellcodezeile mit der Variablendeklara-
tion und -initialisierung betroffen.
• Der Quellcode ist leichter zu lesen, wenn Variablennamen an Stelle von „magischen Zah-
len“ stehen.
Beispiel:
Quellcode Ausgabe
class Prog { Brutto: 119.0
public static void main(String[] args) {
final double mwst = 1.19;
double netto = 100.0, brutto;
brutto = netto * mwst;
System.out.println("Brutto: " + brutto);
}
}

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

final Datentyp Name = Ausdruck ;

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

• das binäre (mit der Basis 2 und den Ziffern 0, 1),


• das oktale (mit der Basis 8 und den Ziffern 0, 1, 2, …, 7)
• und das hexadezimale (mit der Basis 16 und den Ziffern 0, 1, …, 9, A, B, C, D, E, F)
Wenn ein Ganzzahlliteral in einem nicht-dezimalen Zahlensystem interpretiert werden soll, muss
ein Präfix mit einleitender Null vorangestellt werden:
Beispiele
Zahlensystem Präfix
println() - Aufruf Ausgabe
binär 0b, 0B System.out.println(0b11); 3

oktal 0 System.out.println(011); 9

hexadezimal 0x, 0X System.out.println(0x11); 17

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:

3.3.11.6 Referenzliteral null


Einer Referenzvariablen kann das Referenzliteral null zugewiesen werden, z. B.:2
Bruch b1 = null;
Damit ist sie nicht undefiniert, sondern zeigt explizit auf nichts.
Zeigt eine Referenzvariable aktuell auf ein existentes Objekt, kann man diese Referenz per null-
Zuweisung aufheben. Sofern im Programm keine andere Referenz auf dasselbe Objekt vorliegt, ist
es zum Abräumen durch den Garbage Collector der JVM freigegeben.

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;

public class Prog extends Application {


@Override
public void start(Stage primaryStage) {
Alert alert = new Alert(AlertType.INFORMATION, "\u03b1, \u03b2, \u03b3");
alert.setHeaderText("");
alert.showAndWait();
}
public static void main(String[] args) {
launch(args);
}
}
Das Ergebnis:

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

3.4 Eingabe bei Konsolenprogrammen


Konsolenprogramme sind ein geeignetes Umfeld, um die Programmiersprache Java zu erlernen und
mit der Standardbibliothek vertraut zu werden. Bald werden wir selbstverständlich auch die Erstel-
lung von Anwendungen mit grafischer Bedienoberfläche behandeln. Um mit Konsolenanwendun-
gen unsere didaktischen Ziele zu erreichen, benötigen wir eine Möglichkeit, Benutzereingaben ent-
gegenzunehmen. Im aktuellen Abschnitt wird eine Lösung vorgestellt, die sich mit geringem Auf-
wand in unseren Demonstrations- und Übungsprogrammen verwenden lässt.

3.4.1 Die Klassen Scanner und Simput


Für die Übernahme von Tastatureingaben in Konsolenprogrammen kann die API-Klasse Scanner
(Paket java.util, ab Java 9 im Modul java.base) verwendet werden.1 Im folgenden Beispielpro-
gramm zur Berechnung der Fakultät zu einer ganzen Zahl wird ein Scanner-Objekt per nextInt() -
Methodenaufruf gebeten, vom Benutzer eine int-Ganzzahl entgegenzunehmen:
import java.util.Scanner;
class Prog {
public static void main(String[] args) {
Scanner input = new Scanner(System.in);
System.out.print("Argument: ");
int argument = input.nextInt();
double fakul = 1.0;
for (int i = 2; i <= argument; i++)
fakul = fakul * i;
System.out.println("Fakultät: " + fakul);
}
}
Zwei Hinweise zum Quellcode:
• Weil sich die Klasse Scanner im API-Paket java.util befindet, ist eine import-Deklaration
erforderlich, um die Klasse im Quellcode ohne Paket-Präfix ansprechen zu können.
• Die im Programm verwendete for-Wiederholungsanweisung wird im Abschnitt 3.7.3 be-
handelt.
Bei einer gültigen Eingabe arbeitet das Programm wunschgemäß, z. B.:
Argument: 4
Fakultät: 24.0

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

Um Tastatureingaben in Konsolenprogrammen bequem und sicher bewerkstelligen zu können,


wurde für den Kurs eine Klasse namens Simput erstellt.1 Mit Hilfe der Klassenmethode
Simput.gint() lässt sich das Fakultätsprogramm einfacher und zugleich robust gegenüber Ein-
gabefehlern realisieren:
class Prog {
public static void main(String[] args) {
System.out.print("Argument: ");
int argument = Simput.gint();
double fakul = 1.0;
for (int i = 2; i <= argument; i++)
fakul = fakul * i;
System.out.println("Fakultät: " + fakul);
}
}
Weil die Klasse Simput keinem Paket zugeordnet wurde, gehört sie zum Standardpaket und kann
daher in anderen Klassen des Standardpakets bequem ohne Paket-Präfix bzw. Paket-Import ange-
sprochen werden (vgl. Abschnitt 3.1.7). In Klassen anderer Pakete steht Simput (wie alle anderen
Klassen des Standardpakets) jedoch nicht zur Verfügung. Im Kurs erstellen wir meist kleine De-
monstrationsprogramme und verwenden dabei der Einfachheit halber das Standardpaket, sodass die
Klasse Simput als bequemes Hilfsmittel genutzt werden kann. Für ernsthafte Projekte werden Sie
jedoch eigene Pakete definieren (siehe Kapitel 6), sodass die (kompilierte) Klasse Simput dort
nicht verwendbar ist. Diese Einschränkung ist aber leicht durch eine Änderung des Quellcodes in
der Datei Simput.java zu beheben.
Die statische Simput-Methode gint() erwartet vom Benutzer eine per Enter-Taste quittierte
Eingabe und versucht, diese als int-Wert zu interpretieren. Im Erfolgsfall erhält die aufrufende Me-
thode das Ergebnis als gint() - Rückgabewert. Anderenfalls sieht der Benutzer eine Fehlermel-
dung, und die aufrufende Methode erhält den (Verlegenheits-)Rückgabewert 0, z. B.
Argument: vier
Falsche Eingabe!

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

auf den Schalter , entscheiden uns für die Kategorie Java

und wählen anschließend die Datei Simput.jar:

Der Übernahme in das Projekt bzw. Modul Prog, das zum Üben von diversen elementaren Spra-
chelementen dient, kann zugestimmt werden:

Damit die nun definierte IDE-globale Bibliothek Simput


Abschnitt 3.4 Eingabe bei Konsolenprogrammen 125

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

und wählt dann die globale Bibliothek Simput,

die anschließend in der Liste der Abhängigkeiten erscheint:


126 Kapitel 3 Elementare Sprachelemente

Nun können die statischen Methoden der Klasse Simput im Projekt genutzt werden.

3.5 Operatoren und Ausdrücke


Im Zusammenhang mit der Variablendeklaration und der Wertzuweisung haben wir das Sprachele-
ment Ausdruck ohne Erklärung benutzt, und die soll nun nachgeliefert werden. Im aktuellen Ab-
schnitt 3.5 werden wir Ausdrücke als wichtige Bestandteile von Java-Anweisungen detailliert be-
trachten. Dabei lernen Sie elementare Datenverarbeitungsmöglichkeiten kennen, die von sogenann-
ten Operatoren mit ihren Argumenten realisiert werden, z. B. von den arithmetischen Operatoren
(+, -, *, /) für die Grundrechenarten. Im Verlauf des aktuellen Abschnitts werden Ihre Kenntnisse
über die Datenverarbeitung mit Java erheblich wachsen. Der dabei zu investierende Aufwand lohnt
sich, weil ein sicherer Umgang mit Operatoren und Ausdrücken eine unabdingbare Voraussetzung
für das erfolgreiche Implementieren von Methoden ist. Dort werden Algorithmen bzw. die Hand-
lungskompetenzen von Klassen bzw. Objekten realisiert.
Während die Variablen zur Speicherung von Werten dienen, geht es bei den Operatoren darum,
aus vorhandenen Variableninhalten und/oder anderen Argumenten neue Werte zu berechnen. Den
zur Berechnung eines Werts geeigneten, aus Operatoren und zugehörigen Argumenten aufgebauten
Teil einer Anweisung bezeichnet man als Ausdruck, z. B. in der folgenden Wertzuweisung:1
Operator

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

3.5.1 Arithmetische Operatoren


Die arithmetischen Operatoren sind für die Grundrechenarten zuständig, und ihre Operanden (Ar-
gumente) müssen einen primitiven Ganzzahl- oder Gleitkommatyp haben (byte, short, int, long,
char, float oder double). Die resultieren Ausdrücke haben wiederum einen numerischen Ergebnis-
typ und werden oft als arithmetische Ausdrücke bezeichnet.
Es hängt von den Datentypen der Operanden ab, ob bei den Berechnungen die Ganzzahl- oder die
Gleitkommaarithmetik zum Einsatz kommt. Besonders auffällig sind die Unterschiede im Verhal-
ten des Divisionsoperators (dargestellt durch einen Schrägstrich), z. B.:
Quellcode Ausgabe
class Prog { 0
public static void main(String[] args) { 0,66667
int i = 2, j = 3;
double a = 2.0;
System.out.printf("%10d\n", i / j);
System.out.printf("%10.5f", a / j);
}
}

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

mit den beiden int-Variablen i und j äquivalent zu:


i = i + 1;
j = i;
Für den eventuell bei manchen Lesern noch wenig bekannten Modulo-Operator gibt es einige sinn-
volle Anwendungen, z. B.:
• Man kann für eine ganze Zahl bequem feststellen, ob sie gerade (durch 2 teilbar) ist. Dazu
prüft man, ob der Rest aus der Division durch 2 gleich 0 ist:
Quellcode-Fragment Ausgabe
int i = 19;
System.out.println(i % 2 == 0); false

• 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

3.5.4 Identitätsprüfung bei Gleitkommawerten


Bei den binären Gleitkommatypen (float und double) sind simple Identitätstests wegen technisch
bedingter Abweichungen von der reinen Mathematik unbedingt zu unterlassen, z. B.:
Quellcode Ausgabe
class Prog { false
public static void main(String[] args) { true
final double epsilon = 1.0e-14;
double d1 = 10.0 - 9.9;
double d2 = 0.1;
System.out.println(d1 == d2);
System.out.println(Math.abs((d1 - d2)/d1) < epsilon);
}
}

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)));
}
}

Allerdings ist ein erhöhter Speicher- und Zeitaufwand in Kauf zu nehmen.


Den etwas anstrengenden Rest des Abschnitts kann überspringen, wer aktuell keinen Algorithmus
mit auf Identität zu prüfenden double-Werten zu implementieren hat.
Um eine praxistaugliche Identitätsbeurteilung von double-Werten zu erhalten, sollte eine an der
Rechen- bzw. Speichergenauigkeit orientierte Unterschiedlichkeitsschwelle verwendet werden.
Nach diesem Vorschlag werden zwei normalisierte (also insbesondere von null verschiedene)
double-Werte d1 und d2 (vgl. Abschnitt 3.3.7.1) dann als numerisch identisch betrachtet, wenn der
relative Abweichungsbetrag kleiner als 1,010-14 ist:
d1 − d 2
 1,0  10 −14
d1

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,010-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

3.5.5 Logische Operatoren


Aus dem Abschnitt 3.5.3 wissen wir, dass jeder Vergleich (z. B. arg > 0) bereits ein logischer
Ausdruck ist, also die Werte true und false annehmen kann. Durch Anwendung von logischen Ope-
ratoren (Negation, UND, (exklusives) ODER) auf bereits vorhandene logische Ausdrücke kann man
neue, komplexere logische Ausdrücke erstellen. Die Wirkungsweise der in Java unterstützten logi-
schen Operatoren wird anschließend in Wahrheitstafeln beschrieben, wobei die Platzhalter LA,
LA1 und LA2 für logische Ausdrücke stehen.
Um einen logischen Ausdruck LA zu negieren, also die Wahrheitswerte true und false zu vertau-
schen), wendet man den unären logischen Operator ! auf LA an:
Argument Negation
LA !LA
true false
false true

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.

3.5.6 Bitorientierte Operatoren


Über unseren momentanen Bedarf hinausgehend bietet Java einige Operatoren zur bitweisen Analy-
se und Manipulation von Variableninhalten. Statt einer systematischen Darstellung der verschiede-
nen Operatoren (siehe z. B. den Trail Learning the Java Language in den Java Tutorials, Oracle
2021a) beschränken wir uns auf ein Beispielprogramm, das zudem nützliche Einblicke in die Spei-
cherung von char-Werten im Arbeitsspeicher eines Computers erlaubt. Allerdings sind Beispiel und
zugehörige Erläuterungen mit einigen technischen Details belastet. Wenn Ihnen der Sinn momentan
nicht danach steht, können Sie den aktuellen Abschnitt ohne Sorge um den weiteren Kurserfolg an
dieser Stelle verlassen.
Das folgende Programm CharBits liefert die Unicode-Codierung zu einem vom Benutzer erfrag-
ten Zeichen Bit für Bit. Dabei kommt die statische Methode gchar() aus der im Abschnitt 3.4
beschriebenen Klasse Simput zum Einsatz, die das erste Element einer vom Benutzer eingetippten
und mit Enter quittierten Zeichenfolge abliefert. Außerdem kommt mit der for-Schleife eine Wie-
derholungsanweisung zum Einsatz, die erst im Abschnitt 3.7.3.1 offiziell vorgestellt wird. Im Bei-
spiel startet die Indexvariable i mit dem Wert 15, der am Ende jedes Schleifendurchgangs um 1
dekrementiert wird (i--). Ob es zum nächsten Schleifendurchgang kommt, hängt von der Fortset-
zungsbedingung ab (i >= 0):
Quellcode Eingabe (grün, kursiv) u. Ausgabe
class CharBits { Zeichen: x
public static void main(String[] args) { Unicode: 0000000001111000
char cbit; int-Wert: 120
System.out.print("Zeichen: ");
cbit = Simput.gchar();
System.out.print("Unicode: ");
for(int i = 15; i >= 0; i--) {
if ((1 << i & cbit) != 0)
System.out.print("1");
else
System.out.print("0");
}
System.out.println("\nint-Wert: " + (int)cbit);
}
}

Der Links-Shift-Operator << im Ausdruck


1 << i
verschiebt die Bits in der binären Repräsentation der Ganzzahl 1 um i Stellen nach links, wobei am
linken Rand i Stellen verworfen werden, und auf der rechten Seite i Nullen nachrücken. Von den
32 Bits, die ein int-Wert insgesamt belegt (siehe Abschnitt 3.3.6), interessieren im Augenblick nur
die rechten 16. Bei der 1 erhalten wir:
0000000000000001
Im 10. Schleifendurchgang (i = 6) geht dieses Muster z. B. über in:
Abschnitt 3.5 Operatoren und Ausdrücke 139

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.

3.5.7 Typumwandlung (Casting) bei primitiven Datentypen


Wie Sie aus dem Abschnitt 3.3.1 wissen, ist in Java der Datentyp einer Variablen unveränderlich,
und dieses Prinzip wird im aktuellen Abschnitt keineswegs aufgeweicht. Es gibt aber gelegentlich
einen Grund dafür, z. B. den Inhalt einer int-Variablen in eine double-Variable zu übertragen. Auf-
grund der abweichenden Speichertechniken ist dann eine Typanpassung fällig. Das geschieht
manchmal automatisch durch eine Initiative des Compilers, kann aber auch vom Programmierer
explizit angefordert werden.

3.5.7.1 Automatische erweiternde Typanpassung


Beim der Auswertung des Ausdrucks
2.0 / 7
trifft der Divisionsoperator auf ein double- und ein int-Argument, sodass nach der Tabelle im Ab-
schnitt 3.5.1 (Seite 128) die Gleitkommaarithmetik zum Einsatz kommt. Dazu wird für das int-
Argument eine automatische (implizite) Wandlung in den Datentyp double vorgenommen.
Java nimmt bei Bedarf für primitive Datentypen die folgenden erweiternden Typanpassungen
automatisch vor:
byte short int long float double
(8 Bit) (16 Bit) (32 Bit) (64 Bit) (32 Bit) (64 Bit)

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.:

3.5.7.2 Explizite Typumwandlung


Gelegentlich gibt es gute Gründe dafür, über den sogenannten Casting-Operator eine explizite
Typumwandlung zu erzwingen. Im nächsten Beispielprogramm wird mit
(int)'x'
die int-erpretation des (aus dem Abschnitt 3.5.6 bekannten) Bitmusters zum kleinen „x“ vorge-
nommen, damit Sie nachvollziehen können, warum das Beispielprogramm im vorigen Abschnitt
beim „Halbieren“ dieses Zeichens auf den Wert 60 kam:
Quellcode Ausgabe
class Prog { 120
public static void main(String[] args) { 3
System.out.println((int)'x'); 4
2147483647
double a = 3.7615926;
System.out.println((int)a);
System.out.println((int)(a + 0.5));

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

durch die Konvertierung eines double-Werts (mögliches Maximum: 1,797693134862315710308) in


einen short-Wert (mögliches Maximum: 215-1 = 32767) verursacht worden sein. Die kritische Ty-
pumwandlung hatte bei der langsameren Rakete Ariane 4 noch keine Probleme gemacht. Offenbar
sind profunde Kenntnisse über elementare Sprachelemente unverzichtbar für eine erfolgreiche Ra-
ketenforschung und -entwicklung.
Später wird sich zeigen, dass auch zwischen Referenztypen gelegentlich eine explizite Wandlung
erforderlich ist.
Welche expliziten Typkonvertierungen in Java erlaubt sind, ist der Sprachspezifikation zu entneh-
men (Gosling et al. 2021, Abschnitt 5.1).
Die Java-Syntax zur expliziten Typumwandlung:
Typumwandlungs-Operator

( 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

• Auf der linken Seite muss eine Variable stehen.


• Auf der rechten Seite muss ein Ausdruck mit kompatiblem Typ stehen.
• Der zugewiesene Wert stellt auch den Ergebniswert des Ausdrucks dar.
Wie beim Inkrement- bzw. Dekrementoperator sind auch beim Zuweisungsoperator zwei Effekte zu
unterscheiden:
• Die als linkes Argument fungierende Variable erhält einen neuen Wert.
• Es wird ein Wert für den Ausdruck produziert.
Im folgenden Beispiel fungiert ein Zuweisungsausdruck als Parameter für einen println() - Metho-
denaufruf:
Quellcode Ausgabe
class Prog { 4711
public static void main(String[] args) { 4711
int ivar = 13;
System.out.println(ivar = 4711);
System.out.println(ivar);
}
}

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

sind in Java-Programmen gelegentlich anzutreffen, weil Schreibaufwand gespart wird im Vergleich


zur Alternative
j = k;
i = k;
Wie wir seit dem Abschnitt 3.3.8 wissen, stellt ein Zuweisungsausdruck bereits eine vollständige
Anweisung dar, sobald man ein Semikolon dahinter setzt. Dies gilt auch für die Prä- und Pos-
tinkrementausdrücke (vgl. Abschnitt 3.5.1) sowie für Methodenaufrufe, jedoch nicht für die anderen
Ausdrücke, die im Abschnitt 3.5 vorgestellt werden.
Für die häufig benötigten Zuweisungen nach dem Muster
j = j * i;
(eine Variable erhält einen neuen Wert, an dessen Konstruktion sie selbst mitwirkt) bietet Java spe-
zielle Zuweisungsoperatoren für Schreibfaule, die gelegentlich auch als Aktualisierungsoperato-
ren oder als Verbundzuweisungs-Operatoren (engl.: compound assignment operators) bezeichnet
werden. In der folgenden Tabelle steht Var für eine numerische Variable (mit dem Datentyp byte,
short, int, long, char, float oder double) und Expr für einen numerischen Ausdruck:
Beispiel
Operator Bedeutung
Programmfragment Neuer Wert von i
Var += Expr Var erhält den neuen Wert int i = 2; 5
Var + Expr. i += 3;
Var -= Expr Var erhält den neuen Wert int i = 10, j = 3; 1
Var - Expr. i -= j * j;
Var *= Expr Var erhält den neuen Wert int i = 2; 10
Var * Expr. i *= 5;
Var /= Expr Var erhält den neuen Wert int i = 10; 2
Var / Expr. i /= 5;
Var %= Expr Var erhält den neuen Wert int i = 10; 0
Var % Expr. i %= 5;

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

Logischer Ausdruck ? Ausdruck 1 : Ausdruck 2

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);
}
}

Die Auswertung des Ausdrucks ivar * ++ivar verläuft so:


• Zuerst wird der linke Operand der Multiplikation ausgewertet (Ergebnis: 2)
• Dann wird der rechte Operand der Multiplikation ausgewertet:
o Die Präinkrementoperation hat einen Nebeneffekt auf die Variable ivar.
o Der Ausdruck ++ivar hat den Wert 3.
• Die Ausführung der Multiplikationsoperation liefert schließlich das Endergebnis 6.

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);
}
}

resultiert für den Ausdruck ivar++ + ivar * 2 der Wert 8, denn:


• Zuerst wird der linke Operand der Addition ausgewertet:
o Der Ausdruck ivar++ hat den Wert 2.
o Die Postinkrementoperation hat einen Nebeneffekt auf die Variable ivar.
• Dann wird der linke Operand der Multiplikation ausgewertet (Ergebnis: 3).
• Dann wird der rechte Operand der Multiplikation ausgewertet (Ergebnis: 2).
• Dann wird die Multiplikation ausgeführt (Ergebnis: 6).
• Dann wird die Addition ausgeführt (Ergebnis: 8).
Auch bei einem rechts-assoziativen Operator wird der linke Operand vor dem rechten ausgewertet,
sodass im folgenden Beispiel mit der int-Variablen alfa
alfa += ++alfa
148 Kapitel 3 Elementare Sprachelemente

diese Auswertungs- bzw. Ausführungsreihenfolge resultiert:


alfa, ++alfa, +=
Als neuer Wert von alfa entsteht:
alfa + (alfa + 1)
Die oft anzutreffende Behauptung, Klammerausdrücke würden generell zuerst ausgewertet, ist
falsch, wie das folgende Beispiel zeigt:
Quellcode Ausgabe
class Prog { 16
public static void main(String[] args) {
int ivar = 2;
int erg = ivar * (++ivar + 5);
System.out.println(erg);
}
}

Die Auswertung des Ausdrucks ivar * (++ivar + 5) verläuft so:


• Wegen Regel 4 (links-vor-rechts bei der Auswertung der Operanden eines binären Opera-
tors) wird zuerst der linke Operand der Multiplikation ausgewertet (Ergebnis: 2)
• Dann wird der rechte Operand der Multiplikation ausgewertet (also der Klammerausdruck).
• Hier ist mit der Addition eine weitere binäre Operation vorhanden, und nach der Links-vor-
rechts - Regel wird zunächst deren linker Operand ausgewertet (Ergebnis: 3, Nebeneffekt
auf die Variable ivar). Dann wird der rechte Operand der Addition ausgewertet (Ergebnis:
5). Die Ausführung der Additionsoperation liefert für den Klammerausdruck den Wert 8.
• Schließlich führt die Multiplikation zum Endergebnis 16.

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

Operator Bedeutung Operanden

! 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.

3.6 Über- und Unterlauf bei numerischen Variablen


Wie Sie inzwischen wissen, haben die primitiven Datentypen für Zahlen jeweils einen bestimmten
Wertebereich (siehe Tabelle im Abschnitt 3.3.6). Dank strenger Typisierung kann der Compiler
verhindern, dass einer Variablen ein Ausdruck mit „zu großem Typ“ zugewiesen wird. So kann
z. B. einer int-Variablen kein Wert vom Typ long zugewiesen werden. Bei der Auswertung eines
Ausdrucks kann jedoch „unterwegs“ ein Wertebereichsproblem (z. B. ein Überlauf) auftreten. Im
150 Kapitel 3 Elementare Sprachelemente

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.

3.6.1 Überlauf bei Ganzzahltypen


Wird z. B. zu einer ganzzahligen Variablen, die bereits den maximalen Wert ihres Datentyps be-
sitzt, eine positive Zahl addiert, dann kann das Ergebnis nicht mehr korrekt abgespeichert werden.
Ohne besondere Vorkehrungen stellt ein Java-Programm im Falle eines solchen Ganzzahlüberlaufs
keinesfalls seine Tätigkeit mit einem Ausnahmefehler ein, sondern es arbeitet munter weiter. Das
folgende Programm
class Prog {
public static void main(String[] args) {
int i = 2_147_483_647, j = 5, k;
k = i + j; // Überlauf!
System.out.println(i + " + " + j + " = " + k);
}
}
liefert ohne jede Warnung das sinnlose Ergebnis:
2147483647 + 5 = -2147483644
Um das Auftreten eines negativen „Ergebniswerts“ zu verstehen, machen wir einen kurzen Ausflug
in die Informatik. Die Werte eines Ganzzahltyps sind nach dem Zweierkomplementprinzip auf
einem Zahlenkreis angeordnet, und nach der größten positiven Zahl beginnt der Bereich der negati-
ven Zahlen (mit abnehmendem Betrag), z. B. beim Typ byte:
-2 -1 0 1 2

-126 -128 126

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

liefert die Ausgabe:


Double.MAX_VALUE = 1,797693e+308
Double.MaxValue * 10 = Infinity
Unendlich + 10 = Infinity
Unendlich * (-1) = -Infinity
13.0/0.0 = Infinity

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.

3.6.3 Unterlauf bei den Gleitkommatypen


Bei den binären Gleitkommatypen float und double ist auch ein Unterlauf möglich, wobei eine
Zahl mit einem sehr kleinen Betrag nicht mehr dargestellt werden kann. In diesem Fall rechnet ein
Java-Programm mit dem Wert 0,0 weiter, was in der Regel akzeptabel ist, z. B.:
Quellcode Ausgabe
class Prog { 4.9E-324
public static void main(String[] args) { 0.0
double smalld = Double.MIN_VALUE;
System.out.println(smalld);
smalld /= 2.0;
System.out.println(smalld);
}
}

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);
}
}

Das Ergebnis des Ausdrucks


a * b * c
wird halbwegs korrekt ermittelt (vgl. Abschnitt 3.3.7.1 zu den Genauigkeitsproblemen der binären
Gleitkommatypen). Bei der Berechnung des Ausdrucks
a * 0.1 * b * 10.0 * c
wird jedoch das Zwischenergebnis
a * 0.1 = 1E-324 < 4.9E-324
aufgrund eines Unterlaufs auf null gesetzt, und das korrekte Endergebnis 10 kann nicht mehr er-
reicht werden.
Mit Objekten der Klasse BigDecimal aus dem Paket java.math an Stelle von double-Variablen
kann ein Unterlauf zuverlässig verhindert werden:
import java.math.*;
class Prog {
public static void main(String[] args) {
BigDecimal a = new BigDecimal("1E-323");
BigDecimal b = new BigDecimal("1E308");
BigDecimal c = new BigDecimal("1E16");
BigDecimal nk1 = new BigDecimal("0.1");
BigDecimal zehn = new BigDecimal("10.0");
System.out.println(a.multiply(nk1).multiply(b).multiply(zehn).multiply(c));
}
}
Weil BigDecimal-Objekte als Argumente der arithmetischen Operatoren nicht zugelassen sind,
muss das Multiplizieren per Methodenaufruf erledigt werden. Als Gegenleistung für den Aufwand
erhält man das korrekte Ergebnis 10,0 ohne Unterlauf und ohne Genauigkeitsproblem (siehe oben).
Neben dem leicht zu verschmerzenden Schreibaufwand entsteht durch die Verwendung von Big-
Decimal-Objekten aber auch ein erhöhter Speicher- und Rechenzeitaufwand (siehe Abschnitt
3.3.7.2), sodass die binären Gleitkommatypen in vielen Situationen die erste Wahl bleiben.

3.6.4 Modifikator strictfp


In Java 1.2 wurde eine Abweichung von der strikten, an der Norm IEEE-754 orientierten Gleit-
kommaarithmetik zugelassen, um Schwächen der damals (1998) üblichen arithmetischen Coprozes-
soren zu kompensieren. Aufgrund der mittlerweile verbesserten Hardware ist die potentiell störende
Koexistenz zwischen zwei Gleitkommavarianten nicht länger erforderlich und sinnvoll. Daher ist ab
Java 17 nur noch die strikte Gleitkommaarithmetik erlaubt. Der Modifikator strictfp, mit dem zu-
vor für eine Klasse, Schnittstelle (siehe Kapitel 9) oder Methode eine an der strikten IEEE-754 -
Norm orientierte Gleitkommaarithmetik angeordnet werden konnte, ist damit überflüssig geworden.
156 Kapitel 3 Elementare Sprachelemente

Er ist aus Kompatibilitätsgründen weiterhin erlaubt, bewirkt aber ab Java 17 nur noch eine Compi-
ler-Warnung.

3.7 Anweisungen (zur Ablaufsteuerung)


Wir haben uns im Kapitel 3 über elementare Sprachelemente zunächst mit (lokalen) Variablen und
primitiven Datentypen vertraut gemacht. Dann haben wir gelernt, aus Variablen, Literalen und
Methodenaufrufen mit Hilfe von Operatoren mehr oder weniger komplexe Ausdrücke zu bilden.
Diese wurden entweder mit Hilfe des Objekts System.out auf der Konsole ausgegeben oder in
Wertzuweisungen verwendet.
In den meisten Beispielprogrammen traten nur wenige Sorten von Anweisungen auf (Variablende-
klarationen, Wertzuweisungen und Methodenaufrufe). Nun werden wir uns systematisch mit dem
allgemeinen Begriff einer Java-Anweisung befassen und vor allem die wichtigen Anweisungen zur
Ablaufsteuerung (Fallunterscheidungen und Schleifen) kennenlernen.

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 Bedingte Anweisung und Fallunterscheidung


Oft ist es erforderlich, dass eine Anweisung nur unter einer bestimmten Bedingung ausgeführt wird.
Etwas allgemeiner formuliert geht es darum, dass viele Algorithmen Fallunterscheidungen benöti-
gen, also an bestimmten Stellen in Abhängigkeit vom Wert eines steuernden Ausdrucks in unter-
schiedliche Pfade verzweigen müssen.

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

if ( Log. Ausdruck ) 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.

3.7.2.2 if-else - Anweisung


Soll auch etwas passieren, wenn der steuernde logische Ausdruck den Wert false besitzt,

Log. Ausdruck

true false

Anweisung 1 Anweisung 2

dann erweitert man die if-Anweisung um eine else-Klausel.


Zur Beschreibung der if-else - Anweisung wird an Stelle eines Syntaxdiagramms eine alternative
Darstellungsform gewählt, die sich am typischen Java - Quellcode-Layout orientiert:
Abschnitt 3.7 Anweisungen (zur Ablaufsteuerung) 159

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

realisiert z. B. eine if-else - Konstruktion nach diesem Muster:


if (Logischer Ausdruck 1)
Anweisung 1
else if (Logischer Ausdruck 2)
Anweisung 2
. . .
. . .
else if (Logischer Ausdruck k)
Anweisung k
else
Default-Anweisung
Wenn alle logischen Ausdrücke den Wert false annehmen, dann wird die else-Klausel zur letzten if-
Anweisung ausgeführt.
Bei einer Mehrfallunterscheidung ist die im Abschnitt 3.7.2.3 vorzustellende switch-Anweisung
gegenüber einer verschachtelten if-else - Konstruktion zu bevorzugen, wenn die Fallzuordnung über
die verschiedenen Werte eines Ausdrucks (z. B. vom Typ int) erfolgen kann.
Abschnitt 3.7 Anweisungen (zur Ablaufsteuerung) 161

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

Anweisung 1 Anweisung 2 Anweisung 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.

3.7.2.3.1 Traditionelle Syntax


Als Datentyp für den steuernden Ausdruck, den man auch als switch-Argument oder als switch-
Selektor bezeichnet, erlaubt die die traditionelle Syntax:
• Integrale primitive Datentypen mit maximal 4 Bytes:
byte, short, char oder int (nicht long!)
• Verpackungsklassen (siehe Abschnitt 5.3) für integrale primitive Datentypen mit maximal 4
Bytes:
Byte, Short, Character oder Integer (nicht Long!)
• Aufzählungstypen (siehe Abschnitt 5.4)
• Ab Java 7 sind auch Zeichenfolgen (Objekte der Klasse String) erlaubt.
Wegen ihrer großen Variabilität wird die switch-Anweisung mit einem Syntaxdiagramm beschrie-
ben. Wer die Syntaxbeschreibung im Quellcode-Layout bevorzugt, kann ersatzweise einen Blick
auf die gleich folgenden Beispiele werfen.
164 Kapitel 3 Elementare Sprachelemente

switch-Anweisung

switch ( switch-Argument ) {

case Marke : Anweisung break ;

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

und der Ausführungskonfiguration einen neuen Namen geben.


Anschließend lässt sich das Programm innerhalb der Entwicklungsumgebung mit Kommandozei-
lenargumenten ausführen, z. B.:
168 Kapitel 3 Elementare Sprachelemente

3.7.2.3.2 Verbesserte Syntax (ab Java 14)


Mit Java 14 haben signifikante Verbesserungen der switch-Syntax in den Java-Sprachstandard Ein-
zug gehalten. Davon sind in erster Linie die im Abschnitt 3.7.2.4 beschriebenen switch-Ausdrücke
betroffen, doch auch die switch-Anweisungen profitieren von zwei Verbesserungen:
• Fallbearbeitung ohne Durchfall
Vor den Anweisungen zu den Fällen darf statt des Doppelpunkts auch der von Lambda-
Ausdrücken (siehe Kapitel Abschnitt 12) bekannte Pfeil (->) stehen. Bei Verwendung der
Pfeilsyntax findet kein Durchfall zu späteren Fällen statt. Die break-Anweisung ist bei
Verwendung der Pfeilsyntax überflüssig und verboten.
• Vereinfachte Auflistung mehrerer Werte zu einem Fall
Soll für mehrere Werte des switch-Arguments dieselbe Anweisung ausgeführt werden, dann
listet man die Werte hinter dem Schlüsselwort case auf. Das Schlüsselwort case muss also
nicht wiederholt werden. Die Auflistung von Werten lässt sich mit der Doppelpunkt- und
mit der Pfeilsyntax kombinieren.
Das folgende Programm demonstriert die Verbesserungen der switch-Anweisung:
Quellcode Ausgabe
class SwitchJ14 { Fall 1,
public static void main(String[] args) throws Exception { OHNE Durchfall
int zahl = 11;
final int marke1 = 1;
switch (zahl) {
case marke1 ->
System.out.println("Fall 1, \nOHNE Durchfall");
case 2, 3, 4 ->
System.out.println("Fälle 2, 3 und 4");
default ->
System.out.println("Restkategorie");
}
}
}

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

3.7.2.4 switch-Ausdruck (ab Java 14)


Als Alternative zur switch-Anweisung (siehe Abschnitt 3.7.2.3) wurde in Java 14 der switch-
Ausdruck offiziell eingeführt, nachdem dieses Sprachmerkmal seit Java 12 im Vorschaumodus ver-
fügbar war.1
Den Datentyp eines switch-Ausdrucks ermittelt der Compiler aus der Verwendung (z. B. in einer
Wertzuweisung oder in einer return-Anweisung, siehe Abschnitt 4.3.1.2). Zu jedem Wert des
switch-Arguments ist ein Wert mit einem kompatiblen Datentyp zu liefern.2
Wie bei der switch-Anweisung kann man auch beim switch-Ausdruck seit Java 14 die Falldefiniti-
onen mit einem Doppelpunkt oder mit einem Lambda-Pfeil von den Fallbehandlungen trennen. Im
folgenden Beispiel wird der Doppelpunkt verwendet, und die Ergebniswerte zu den Fällen werden
über eine yield-Anweisung geliefert:
Quellcode Ausgabe
class SwitchJ14 { Fall 1,
public static void main(String[] args) { OHNE Durchfall
int zahl = 1;
final int marke1 = 1;

String swr = switch(zahl) {


case marke1:
yield "Fall 1,\nOHNE Durchfall";
case 2, 3, 4:
yield "Fälle 2, 3 und 4";
default:
yield "Restkategorie";
};
System.out.println(swr);
}
}

Im Beispiel wird der switch-Ausdruck im Rahmen einer Variablendeklarationsanweisung mit Ini-


tialisierung verwendet, die mit einem Semikolon enden muss. Für den switch-Ausdruck resultiert
der Datentyp String.
An der Stelle einer einfachen yield-Anweisung kann auch ein Anweisungsblock stehen, der mit
einer yield-Anweisung endet, z. B.:

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

String swr1 = switch (zahl) {


case marke1:
yield "Fall 1, \nOHNE Durchfall";
case 2, 3, 4:
yield "Fälle 2, 3 und 4";
default:
{System.out.print("default: "); yield "Restkategorie";}
};
Die yield-Anweisung muss am Ende des Blocks stehen, weil sie zum Verlassen des switch-
Ausdrucks führt. In einem Block hinter der yield-Anweisung stehende Anweisungen wären nicht
erreichbar.
Im Vergleich zur Verwendung der Doppelpunktsyntax in einer switch-Anweisung bestehen zwei
Unterschiede:
• Der für jeden Fall obligatorische Wert ist per yield-Anweisung anzugeben.
• Es muss kein Durchfall per break verhindert werden, wobei man allerdings von einer
Durchfall-Prävention per yield sprechen könnte.
Für einen switch-Ausdruck ist in der Regel die Lambda- oder Pfeilsyntax gegenüber der Doppel-
punktsyntax zu bevorzugen. Man schreibt den Ausdruck zu einem Fall unmittelbar hinter den Pfeil
und erspart sich die yield-Anweisung:
Quellcode Ausgabe
class SwitchJ14 { Fall 1 (OHNE Durchfall)
public static void main(String[] args) throws Exception {
int zahl = 1;
final int marke1 = 1;
String swr = switch (zahl) {
case marke1 -> "Fall 1 (OHNE Durchfall)";
case 2, 3, 4 -> "Fälle 2, 3 und 4";
default -> "Restkategorie";
};
System.out.println(swr);
}
}

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.:

3.7.2.5 Mustervergleich (Vorschau in Java 17)


Der in Java 17 eingeführten Mustervergleich (engl.: pattern matching) erlaubt bei switch-
Anweisungen und -Ausdrücken eine flexible Falldefinition. Für das switch-Argument wird in der
Regel ein breiter Referenzdatentyp verwendet, zu dem viele Ableitungen (Spezialisierungen) exis-
tieren. Die switch-Fälle werden nicht durch eine Konstante oder eine Liste von Konstanten defi-
niert, sondern durch einen Referenztyp, der optional durch einen logischen Ausdruck näher be-
stimmt werden kann.
Im folgenden Beispiel mit einem switch-Ausdruck vom Typ boolean hat das Argument den maxi-
mal breiten (allgemeinen) Typ Object:
class Prog {
public static void main(String[] args) {
Object s = "123";
boolean result = switch (s) {
case String a -> a.length() == 3;
case Integer i -> i >= 5 && i <= 10;
default -> false;
};
System.out.println(result);
}
}

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

boolean result = switch (s) {


case String a && a.length() == 3 -> true;
case Integer i && i >= 5 && i <= 10 -> true;
default -> false;
};
Man kann also ein Typmuster durch einen logischen Ausdruck ergänzen und dabei die Mustervari-
able verwenden. So ist es z. B. möglich, in die Falldefinition zu einem numerischen Argument eine
Bereichsangabe aufzunehmen.
Im logischen Ausdruck dürfen auch Klammern zur Steuerung der Auswertungsreihenfolge auftreten
(vgl. Abschnitt 3.5.10), z. B.:
case Integer i && i%2 == 0 && (i < 10 || i > 100) -> true;
Für die beschriebenen Muster werden gelegentlich die folgenden Begriffe verwendet:
• Typmuster (engl.: type pattern), z. B.:
case Integer i -> i >= 5 && i <= 10;
• Geschütztes Muster (engl.: guarded pattern), z. B.:
case Integer i && i >= 5 && i <= 10 -> true;
• Klammermuster (engl.: parenthesized pattern), z. B.:
case Integer i && i%2 == 0 && (i < 10 || i > 100) -> true;

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;
};

In den bisherigen Beispielen wurden Mustervergleiche im Rahmen von switch-Ausdrücken ver-


wendet. Man kann sie aber auch im Rahmen von switch-Anweisungen einsetzen, z. B.:
int resSt;
switch (s) {
case Integer i && i >= 5 && i <= 10 -> resSt = i;
case String a -> resSt = a.length();
default -> resSt = -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++;

Zur Realisation von iterativen Algorithmen enthält Java verschiedene Wiederholungsanweisungen


(jeweils bestehend aus einer Schleifensteuerung und einer wiederholt auszuführenden Anweisung),
die später in eigenen Abschnitten behandelt und hier mit vereinfachter Beschreibung im Überblick
präsentiert werden:
• Zählergesteuerte Schleife (for)
Bei der Ablaufsteuerung kommt eine Zähl- oder Laufvariable zum Einsatz, die vor dem
ersten Schleifendurchgang initialisiert und nach jedem Durchlauf aktualisiert (z. B. inkre-
mentiert) wird. Die zur Schleife gehörige (Verbund-)Anweisung wird ausgeführt, solange
die Zählvariable einen festgelegten Grenzwert nicht überschritten hat.
• Iterieren über die Elemente einer Kollektion (for)
Seit der Java-Version 5 (alias 1.5) ist es mit einer Variante der for-Schleife möglich, eine
Anweisung für jedes Element eines Arrays oder einer anderen Kollektion (siehe Kapitel 10)
ausführen zu lassen.
• Bedingungsabhängige Schleife (while, do)
Bei jedem Schleifendurchgang wird eine Bedingung überprüft, und das Ergebnis entschei-
det über das weitere Vorgehen:
o true: Die zur Schleife gehörige Anweisung wird ein weiteres Mal ausgeführt.
o false: Die Schleife wird beendet.
Bei der kopfgesteuerten while-Schleife wird die Bedingung vor Beginn eines Durchgangs
geprüft, bei der fußgesteuerten do-Schleife hingegen am Ende. Weil man z. B. nach dem 3.
Schleifendurchgang in keiner anderen Lage ist wie vor dem 4. Schleifendurchgang, geht es
bei der Entscheidung zwischen Kopf- und Fußsteuerung lediglich darum, ob auf jeden Fall
ein erster Schleifendurchgang stattfinden soll (do-Schleife) oder nicht (while-Schleife).
Die gesamte Konstruktion aus Schleifensteuerung und (Verbund-)anweisung stellt in syntaktischer
Hinsicht eine zusammengesetzte Anweisung dar.
Abschnitt 3.7 Anweisungen (zur Ablaufsteuerung) 177

3.7.3.1 Zählergesteuerte Schleife (for)


Die Anweisung einer for-Schleife wird ausgeführt, solange eine Bedingung erfüllt ist, die norma-
lerweise auf eine ganzzahlige Zählvariable Bezug nimmt.
Auf das Schlüsselwort for folgt die von runden Klammern umgebene Schleifensteuerung, wo die
Vorbereitung der Laufvariablen (nötigenfalls samt Deklaration), die Fortsetzungsbedingung und die
Aktualisierungsvorschrift untergebracht werden. Danach folgt die wiederholt auszuführende
(Block-)Anweisung:
for (Vorbereitung; Bedingung; Aktualisierung)
Anweisung
Zu den drei Bestandteilen der Schleifensteuerung sind einige Erläuterungen erforderlich, wobei hier
etliche weniger typische bzw. sinnvolle Möglichkeiten weggelassen werden:
• Vorbereitung
In der Regel wird man sich auf eine Laufvariable beschränken und dabei einen Ganzzahltyp
wählen. Somit kommen im Vorbereitungsteil der for-Schleifensteuerung in Frage:
o eine Variablendeklaration mit Initialisierung, z. B.:
long i = 1
o eine Wertzuweisung für eine bereits deklarierte Variable, z. B.:
i = 1
Im folgenden Programm, das die Summe der quadrierten natürlichen Zahlen von 1 bis 5 be-
rechnet, kommt die erste Variante zum Einsatz:
Quellcode Ausgabe
class Prog { Quadratsumme = 55.0
public static void main(String[] args) {
double summe = 0.0;
for (long i = 1; i <= 5; i++)
summe += i*i;
System.out.println("Quadratsumme = " + summe);
}
}

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.2 Iterieren über die Elemente von Arrays oder Kollektionen


Obwohl wir uns bisher mit Arrays (Objekten, die eine feste Anzahl von Elementen desselben Da-
tentyps enthalten, siehe Abschnitt 5.1) nur anhand eines Beispiels (siehe Abschnitt 3.7.2.3) und mit
Kollektionen (Containern für Elemente, siehe Kapitel 10) noch überhaupt nicht beschäftigt haben,
soll die mit Java 5 (alias 1.5) eingeführte for-Schleifen - Variante für Arrays und Kollektionen doch
hier im Kontext mit den übrigen Wiederholungsanweisungen behandelt werden. Offiziell ist von
der erweiterten for-Anweisung (engl.: enhanced for statement) die Rede (siehe z. B. Gosling et al.
2021, Abschnitt 14.14.2). Bloch (2018, S. 264) bevorzugt die Bezeichnung for-each - Schleife.
Konzentrieren Sie sich bitte auf das gleich präsentierte, leicht nachvollziehbare Beispiel, und lassen
Sie sich durch die Begriffe Array, Kollektion und Interface, die zu später behandelten Themen ge-
hören, nicht beunruhigen.
Im Abschnitt 3.7.2.3 wurde bereits demonstriert, wie man über den String[] - Parameter der Me-
thode main() auf die Zeichenfolgen zugreifen kann, die der Benutzer beim Programmstart als Ar-
gumente angegeben hat. Im folgenden Programm wird durch eine erweiterte for-Schleife jedes
Element im String-Array args mit den Programmargumenten ausgegeben:
Ausgabe nach einem Start mit
Quellcode
>java Prog eins zwei drei
class Prog { eins
public static void main(String[] args) { zwei
for (String s : args) drei
System.out.println(s);
}
}

Die Syntax der for-Variante für Kollektionen:


for (Elementtyp Iterationsvariable : Serie)
Anweisung
Als Serie erlaubt der Compiler:
Abschnitt 3.7 Anweisungen (zur Ablaufsteuerung) 179

• einen Array (siehe Beispiel)


• ein Objekt einer Klasse, welche das Interface Iterable oder das generische Interface
Iterable<T> implementiert (siehe Kapitel 9 und Abschnitt 10.5)
Im Schleifenkopf wird eine Iterationsvariable vom Datentyp der Serienelemente deklariert. Die
Anweisung wird nacheinander für jedes Element der Serie ausgeführt, wobei die Iterationsvariable
das gerade in Bearbeitung befindliche Serienelement anspricht.
Bloch (2018, S. 264ff) empfiehlt wegen der folgenden Vorteile, die for-each - Schleife nach Mög-
lichkeit gegenüber der traditionellen for-Schleife zu bevorzugen:
• Oft werden die Flexibilität (und der Aufwand) bei der Initialisierung, Überprüfung und Ak-
tualisierung der Indexvariablen nicht benötigt. In der for-each - Syntax beschränkt man sich
auf die Elementzugriffsvariable und erhält einen besser lesbaren Quellcode.
• Durch den Verzicht auf eine Indexvariable entfallen Fehlermöglichkeiten.
• Weil in der for-each - Schleife für einen Array und für eine Kollektion dieselbe Syntax ver-
wendet wird, macht der Wechsel der Container-Architektur wenig Aufwand.
Eine Einschränkung der for-Schleife für Arrays und Kollektionen besteht darin, dass man in ihrer
Anweisung die Serienelemente nicht verändern kann, sodass z. B. der folgende Versuch zum „Lö-
schen“ der Elemente im String-Array args misslingt:
for (String s : args) {
s = "erased";
}
Es liegt kein Syntaxfehler vor, doch die Anweisung hat keinen Effekt, weil die Iterationsvariable
jeweils eine Kopie des aktuellen Serienelements erhält, sodass sich ein schreibender Zugriff nur
innerhalb der for-Anweisung auswirkt.
Über eine traditionelle for-Schleife (vgl. Abschnitt 3.7.3.1) ist der Plan aus dem letzten Beispiel
durchaus zu realisieren, wie das folgende Programm zeigt:
Ausgabe nach einem Start mit
Quellcode
java Prog eins zwei drei
class Prog { eins
public static void main(String[] args) { zwei
for (String s : args) drei
s = "erased"; erased
for (String s : args) erased
System.out.println(s); erased

for (int i = 0; i < args.length; i++)


args[i] = "erased";
for (String s : args)
System.out.println(s);
}
}

3.7.3.3 Bedingungsabhängige Schleifen


Wie die Erläuterungen zur for-Schleife gezeigt haben, ist die Überschrift zu diesem Abschnitt nicht
sehr trennscharf, weil bei der for-Schleife ebenfalls eine beliebige Terminierungsbedingung ange-
geben werden darf. In vielen Fällen ist es eine Frage des persönlichen Geschmacks, welche Wie-
derholungsanweisung man zur Lösung eines konkreten Iterationsproblems einsetzt. Unter der aktu-
ellen Abschnittsüberschrift werden traditionsgemäß die while- und die do-Schleife diskutiert.
180 Kapitel 3 Elementare Sprachelemente

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

Das im Abschnitt 3.7.3.1 vorgestellte Beispielprogramm zur Quadratsummenberechnung mit Hilfe


einer for-Schleife kann leicht auf die Verwendung einer while-Schleife umgestellt werden:
Quellcode Ausgabe
class Prog { Quadratsumme = 55.0
public static void main(String[] args) {
long i = 1;
double summe = 0.0;
while (i <= 5) {
summe += i*i;
i++;
}
System.out.println("Quadratsumme = " + summe);
}
}

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

3.7.3.5 Schleifen(durchgänge) vorzeitig beenden


Mit der break-Anweisung, die uns schon im Abschnitt 3.7.2.3 als Bestandteil der switch-
Anweisung begegnet ist, kann eine Schleife vorzeitig verlassen werden, wobei die Methode hinter
der Schleife fortgesetzt wird.
Mit der continue-Anweisung erreicht man, dass der aktuelle Schleifendurchgang beendet und sofort
mit dem nächsten begonnen wird (bei for und while nach Prüfung der Fortsetzungsbedingung). In
der Regel kommen break und continue im Rahmen einer if-Anweisung zum Einsatz, z. B. im fol-
genden Programm zur (relativ simplen) Primzahlendiagnose:
class Primitiv {
public static void main(String[] args) {
boolean tg;
long i, mtk, zahl;
System.out.println("Einfacher Primzahlendetektor\n");
while (true) {
System.out.print("Zu untersuchende ganze Zahl von 2 bis 2^63-1 oder 0 zum Beenden: ");
zahl = Simput.glong();
if (Simput.checkError() || zahl < 0) {
System.out.println("Keine ganze Zahl (im zulässigen Bereich)!\n");
continue;
}
if (zahl == 1) {
System.out.println("1 ist per Definition keine Primzahl.\n");
continue;
}
if (zahl == 0)
break;
tg = false;
mtk = (long) Math.sqrt(zahl); //maximaler Teilerkandidat
for (i = 2; i <= mtk; i++)
if (zahl % i == 0) {
tg = true;
break;
}
if (tg)
System.out.println(zahl + " ist keine Primzahl (Teiler: " + i + ").\n");
else
System.out.println(zahl + " ist eine Primzahl.\n");
}
System.out.println("\nVielen Dank für den Einsatz dieser Software!");
}
}

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

statischen Simput-Methode checkError(). Ist ein Fehler aufgetreten, liefert checkError()


den Wert true zurück.
Nach einer irregulären Eingabe erscheint eine Fehlermeldung auf der Konsole, und der aktuelle
Durchgang der while-Schleife wird per continue verlassen.
Auch nach der Eingabe 1 wird der aktuelle Schleifendurchgang per continue verlassen, weil keine
Prüfung erforderlich ist. Der Benutzer wird darüber informiert, dass die 1 per Definition keine
Primzahl ist.
Durch Eingabe der Zahl 0 kann das Programm beendet werden, wobei die absichtlich konstruierte
while - „Endlosschleife“ per break verlassen wird.
Man hätte die continue-Anweisung und die break-Anweisungen zwar vermeiden können (siehe
Übungsaufgabe auf Seite 192), doch werden beim vorgeschlagenen Verfahren lästige Sonderfälle
(unzulässige Werte, 0 als Terminierungssignal) auf besonders übersichtliche Weise abgehakt, bevor
der Kernalgorithmus startet.
Wie bei der switch-Anweisung bestehen auch bei einer Schleife zur Beendigung per break die fol-
genden Alternativen:
• return-Anweisung
Über die return-Anweisung (siehe Abschnitt 4.3.1.2) wird nicht nur Schleife, 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 Schleife verlas-
sen werden, wobei das weitere Verhalten des Programms davon anhängt, ob und wo der
Ausnahmefehler aufgefangen wird.
Zum Verfahren der Primzahlendiagnose sollte noch erläutert werden, warum die Suche nach einem
Teiler des Primzahlkandidaten bei seiner Wurzel enden kann (genauer: bei der größten ganzen Zahl
 Wurzel):
Sei d ( 2) ein echter Teiler der positiven, ganzen Zahl z, d. h. es gibt eine Zahl k ( 2) mit
z=kd
Dann ist auch k ein echter Teiler von z, und es gilt:
d z oder k  z
Anderenfalls wäre das Produkt k  d größer als z. Wir haben also folgendes Ergebnis: Wenn eine
Zahl z einen echten Teiler hat, dann besitzt sie auch einen echten Teiler kleiner oder gleich z.
Wenn man keinen echten Teiler kleiner oder gleich z gefunden hat, kann man die Suche einstel-
len, und z ist eine Primzahl.
Ist z. B. die Primzahl 23 mit der Wurzel 4,796... zu untersuchen, dann kann die Suche mit dem
Teilerkandidaten 4 enden (größte ganze Zahl  Wurzel). Es sind also nur die Teilerkandidaten 2, 3
und 4 zu untersuchen, sodass viel sinnloser Aufwand eingespart wird. Nach dem Teilerkandidaten 2
auch noch ein Vielfaches dieser Zahl (z. B. 4) zu untersuchen, ist natürlich nicht sehr intelligent.
Daher trägt das Programm den Namen Primitiv.
Zur Berechnung der Quadratwurzel verwendet das Beispielprogramm die statische Methode sqrt()
aus der Klasse Math, über die man sich bei Bedarf in der API-Dokumentation informieren kann.
In der for-Schleife zur Suche nach einem Teiler wird der Laufindex i (Datentyp long) wiederholt
mit dem maximalen Teilerkandidaten in der Variablen mtk vergleichen. Damit dabei keine implizi-
184 Kapitel 3 Elementare Sprachelemente

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.

3.8 Entspannungs- und Motivationseinschub: GUI-Standarddialoge


Nach etlichen recht anstrengenden Themen, soll dieser Abschnitt zur Entspannung und zur Regene-
ration Ihrer Motivation beitragen. Sie lernen GUI-Standarddialoge (Graphical User Interface) zur
Abfrage von Werten und zur Präsentation von Meldungen kennen, die die Klasse JOptionPane aus
dem Paket javax.swing über statische Methoden zur Verfügung stellt. Den Standarddialog zur Mel-
dungsausgabe haben wir in seiner einfachsten Form übrigens schon im Abschnitt 3.3.11.5 verwen-
det.
Es wirkt inkonsequent, trotz der erklärten Absicht zum Wechsel von der traditionellen GUI-Lösung
Swing auf die moderne Alternative JavaFX (alias OpenJFX) an dieser Stelle die Klasse
JOptionPane aus dem Paket javax.swing zu verwenden. Allerdings wurde schon im Abschnitt
3.3.11.5 demonstriert, dass es nur mit der Swing-Klasse JOptionPane gelingt, ohne Kontakt mit
anspruchsvollen Java-Themen eine elementare GUI-Anwendung zu erstellen. Außerdem sind
Kenntnisse über die Klasse JOptionPane von Nutzen, weil sie in vielen vorhandenen Programmen
anzutreffen ist.
Wir erstellen zum Primzahldiagnoseprogramm aus dem Abschnitt 3.7.3.5 mit geringem Aufwand
die folgende Variante
Abschnitt 3.8 Entspannungs- und Motivationseinschub: GUI-Standarddialoge 185

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);
}
}
}

mit grafischer Bedienoberfläche:

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.9 Übungsaufgaben zum Kapitel 3

Abschnitt 3.1 (Einstieg)


1) Welche main() - Varianten sind zum Starten eines Programms geeignet?
public static void main(String[] irrelevant) { … }
public void main(String[] args) { … }
public static void main() { … }
static public void main(String[] args) { … }
public static void main(String[] Args) { … }
static public void Main(String[] args) { … }
static public int main(String[] args) { … }

2) Welche von den folgenden Bezeichnern sind unzulässig?


4you
main
else
Alpha
lösung

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?

Abschnitt 3.2 (Ausgabe bei Konsolenanwendungen)


1) Schreiben Sie ein Programm, das die Klassenvariable PI aus der API-Klasse Math wiederholt
mit verschiedener Genauigkeit linksbündig ausgibt:
3,1 3,14 3,142
3,1416 3,14159 3,141593

2) Wie ist das fehlerhafte „Rechenergebnis“ des folgenden Programms zu erklären?


Quellcode Ausgabe
class Prog { 3.3 + 2 = 3.32
public static void main(String[] args) {
System.out.println("3.3 + 2 = " + 3.3 + 2);
}
}

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

Abschnitt 3.3 (Variablen und Datentypen)


1) Entlarven Sie wieder einmal falsche Behauptungen:
1. Die lokalen Variablen einer Methode haben stets einen primitiven Datentyp.
2. Die lokalen Variablen befinden sich während der Methodenausführung auf dem Stack.
3. Referenzvariablen werden auf dem Heap abgelegt.
4. Bei der objektorientierten Programmierung sollten möglichst keine primitiven Variablen
verwendet werden.

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"

5) Beseitigen Sie bitte alle Fehler im folgenden Programm:


class Prog {
static void main(String[] args) {
float PI = 3,141593;
double radius = 2,0;
System.out.printLine("Der Flächeninhalt beträgt: + PI*radius*radius);
}
}
190 Kapitel 3 Elementare Sprachelemente

Abschnitt 3.4 (Eingabe bei Konsolen)


1) Richten Sie bitte in IntelliJ nach der Beschreibung im Abschnitt 3.4.2 eine globale Bibliothek mit
der Klasse Simput ein. Nehmen Sie im IntelliJ-Projekt Prog, das wir zum Üben von elementaren
Sprachelementen verwenden (siehe Abschnitt 3.1.2), die Bibliothek mit der Klasse Simput in die
Liste der Abhängigkeiten zum einzig vorhandenen Modul auf. Lassen Sie das im Abschnitt 3.4.1
beschriebene Fakultätsprogramm mit Simput.gint() - Aufruf laufen, und testen Sie auch die
Simput-Methoden gdouble() und gchar().

Abschnitt 3.5 (Operatoren und Ausdrücke)


1) Welche Werte und Datentypen besitzen die folgenden Ausdrücke?
6/4*2.0
(int)6/4.0*3
(int)(6/4.0*3)
3*5+8/3%4*5

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';

la1 = 2 < 3 && 2 == 2 ^ 1 == 1;


System.out.println(la1);

la2 = (2 > 3 && 2 == 2) ^ (1 == 1);


System.out.println(la2);

la3 = !(i > 0 || c == 'j');


System.out.println(la3);
}
}

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).

Abschnitt 3.6 (Über- und Unterlauf bei numerischen Variablen)


1) Welche der folgenden Aussagen sind richtig bzw. falsch?
1. Kommt es bei einer Ganzzahlvariablen zum Überlauf, dann stoppt das Programm mit einem
Laufzeitfehler.
2. Bei Objekten der Klasse BigDecimal kann weder ein Über- noch ein Unterlauf auftreten.
3. Bei einer versuchten Gleitkommadivision durch null stoppt das Programm mit einem Lauf-
zeitfehler.
4. Man sollte bei numerischen Aufgaben grundsätzlich Objekte aus den Klassen BigDecimal
und BigInteger verwenden.
192 Kapitel 3 Elementare Sprachelemente

Abschnitt 3.7 (Anweisungen (zur Ablaufsteuerung))


1) Warum liefert dieses Programm widersprüchliche Auskünfte über die boolesche Variable b?
Quellcode Ausgabe
class Prog { b ist true
public static void main(String[] args) {
boolean b = true; Kontr.ausgabe: b ist false
if (b = false)
System.out.println("b ist false");
else
System.out.println("b ist true");
System.out.println("\nKontr.ausgabe: b ist "+b);
}
}

2) Das folgende Programm soll Buchstaben nummerieren:


Quellcode Ausgabe
class Prog { Zu a gehört die Nummer 3
public static void main(String[] args) {
char bst = 'a';
byte nr = 0;

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.

4) Wie oft wird die folgende while-Schleife ausgeführt?


class Prog {
public static void main(String[] args) {
int i = 0;
while (i < 100);
{
i++;
System.out.println(i);
}
}
}

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)

Erste Zahl: 999000999 Erste Zahl: 999000999

Zweite Zahl: 36 Zweite Zahl: 36

GGT: 9 GGT: 9

Benötigte Zeit: 32 Millisek. Benötigte Zeit: 0 Millisek.

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).

4.1 Überblick, historische Wurzeln, Beispiel

4.1.1 Einige Kernideen und Vorzüge der OOP


Lahres & Rayman (2009, Kapitel 2) nennen in ihrem Praxisbuch Objektorientierung unter Beru-
fung auf Alan Kay, der den Begriff Objektorientierte Programmierung geprägt und die objektori-
entierte Programmiersprache Smalltalk entwickelt hat, als unverzichtbare OOP-Grundelemente:
• Datenkapselung
Eine Klasse erlaubt in der Regel fremden Klassen keinen direkten Zugriff auf ihre Zustands-
daten. So wird das Risiko für das Auftreten inkonsistenter Zustände reduziert. Außerdem
kann der Klassendesigner Implementierungsdetails ohne Nebenwirkungen auf andere Klas-
sen ändern. Mit der Datenkapselung haben wir uns schon im Abschnitt 1.1 beschäftigt.
• Vererbung
Aus einer vorhandenen Klasse lassen sich zur Lösung neuer Aufgaben spezialisierte Klassen
ableiten, die alle Member der Basisklasse erben. Hier findet eine Wiederverwendung von
Software ohne lästiges und fehleranfälliges Kopieren von Quellcode statt. Beim Design der
abgeleiteten Klasse kann man sich darauf beschränken, neue Member zu definieren oder bei
manchen Erbstücken (z. B. Methoden) Modifikationen zur Anpassung an die neue Aufgabe
vorzunehmen.
• Polymorphie
Über Referenzvariablen vom Typ einer Basisklasse lassen sich auch Objekte von abgeleite-
ten Klassen verwalten, wobei selbstverständlich nur solche Methoden aufgerufen werden
dürfen, die schon in der Basisklasse definiert sind. Ist eine solche Methode in abgeleiteten
Klassen unterschiedlich implementiert, führt jedes per Basisklassenreferenz angesprochene
Objekt sein angepasstes Verhalten aus. Derselbe Methodenaufruf hat also unterschiedliche
(polymorphe) Verhaltensweisen zur Folge. Welche Methode ausgeführt wird, entscheidet
sich erst zur Laufzeit (späte Bindung). Dank Polymorphie ist eine lose Kopplung von Klas-
sen möglich, und die Wiederverwendbarkeit von vorhandenem Code wird verbessert.
Java bietet sehr gute Voraussetzungen zur Nutzung dieser Konstruktionsprinzipien beim Entwurf
von stabilen, wartungsfreundlichen, anpassungsfähigen und auf Wiederverwendung angelegten
Software-Systemen, kann aber keinen Entwickler zur Realisation der Prinzipien zwingen.

4.1.1.1 Datenkapselung und Modularisierung


In der objektorientierten Programmierung (OOP) wird die vorher übliche Trennung von Daten und
Operationen überwunden. Ein objektorientiertes Programm besteht aus Klassen, die durch Felder
(also Daten) und Methoden (also Operationen) definiert sind. Eine Klasse wird in der Regel ihre
Felder gegenüber anderen Klassen verbergen (Datenkapselung, engl.: information hiding) und so
vor ungeschickten Zugriffen schützen. Die meisten Methoden einer Klasse sind hingegen von außen
ansprechbar und bilden ihre Schnittstelle bzw. ihr API. Dies kommt in der folgenden Abbildung
zum Ausdruck, die Sie schon aus dem Abschnitt 1.1.1 kennen:
Abschnitt 4.1 Überblick, historische Wurzeln, Beispiel 197

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

• Vermeidung von Fehlern


Direkte Schreibzugriffe auf die Felder einer Klasse bleiben den klasseneigenen Methoden
vorbehalten, die vom Designer der Klasse sorgfältig entworfen wurden. Damit sollten Pro-
grammierfehler nur sehr selten auftreten. In unserer Beispielklasse Bruch haben wir dafür
gesorgt, dass unter keinen Umständen der Nenner eines Bruchs auf den Wert 0 gesetzt wird.
Anwender unserer Klasse können einen Nenner einzig über die Methode setzeNenner()
verändern, die aber den Wert 0 nicht akzeptiert. Bei einer anderen Klasse mag es wichtig
sein, dass für eine Gruppe von Feldern bei jeder Änderung gewisse Konsistenzbedingungen
(sogenannte Invarianten) eingehalten werden.
• Günstige Voraussetzungen für das Testen und die Fehlerbereinigung
Treten in einem Programm trotz Datenkapselung pathologische Variablenausprägungen auf,
ist die Ursache relativ leicht aufzuklären, weil nur wenige Methoden verantwortlich sein
können. Zur Sicherstellung wichtiger Bedingungen kann es sinnvoll sein, auch Feldzugriffe
durch klasseneigene Methoden über die zuständigen Zugriffsmethoden vorzunehmen. Bei
der Software-Entwicklung im professionellen Umfeld spielt das systematische Testen eines
Programms (Unit Testing) eine entscheidende Rolle. Ein objektorientiertes Softwaresystem
mit Datenkapselung und guter Modularisierung bietet günstige Voraussetzungen für eine
möglichst umfassende Testung.
• Innovationsoffenheit durch gekapselte Details der Klassenimplementation
Verborgene Details einer Klassenimplementation kann der Designer ändern, ohne die Ko-
operation mit anderen Klassen zu gefährden.
• Produktivität durch wiederholt und bequem verwendbare Klassen
Selbständig agierende Klassen, die ein Problem ohne überflüssige Abhängigkeiten von an-
deren Klassen lösen, sind potenziell in vielen Projekten zu gebrauchen (Wiederverwendbar-
keit). Wer als Programmierer eine Klasse verwendet, braucht sich um deren inneren Aufbau
nicht zu kümmern, sodass neben dem Fehlerrisiko auch der Einarbeitungsaufwand sinkt.
Man kann z. B. in einem GUI-Programm einen kompletten Rich-Text-Editor über eine Klas-
se aus der Standardbibliothek integrieren, ohne wissen zu müssen, wie Text und Textaus-
zeichnungen intern verwaltet werden.
• Erfolgreiche Teamarbeit durch abgeschottete Verantwortungsbereiche
In großen Projekten können mehrere Programmierer nach der gemeinsamen Definition von
Schnittstellen relativ unabhängig an verschiedenen Klassen arbeiten.
Durch die objektorientierte Programmierung werden auf vielfältige Weise Kosten reduziert:
• Vermeidung bzw. schnelle Aufklärung von Programmierfehlern
• gute Chancen für die Wiederverwendung von Software
• gute Voraussetzungen für die Kooperation in Teams

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.Component java.lang.System Bruch

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:

Methodenaufruf, Referenz auf Methodenaufruf, gerichtet an das von


gerichtet an das ein Bruch- getClass() gelieferte Class-Objekt,
PrintStream-Objekt out Objekt liefert ein String-Objekt zurück

System.out.println(b.getClass().getName());

(statische) Referenz Methodenaufruf, gerichtet an das


auf ein Bruch-Objekt b, mit Referenz auf ein
PrintStream-Objekt Class-Objekt als Rückgabewert
200 Kapitel 4 Klassen und Objekte

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.

4.1.1.4 Realitätsnahe Modellierung


Klassen sind nicht nur ideale Bausteine für die rationelle Konstruktion von Software-Systemen,
sondern sie erlauben auch eine gute Modellierung des Anwendungsbereichs. In der zentralen Pro-
jektphase der objektorientierten Analyse und Modellierung sprechen Software-Entwickler und Auf-
traggeber dieselbe Sprache, sodass Kommunikationsprobleme weitgehend vermieden werden.
Neben den Klassen zur Modellierung von Akteuren oder Ereignissen des realen Anwendungsbe-
reichs sind bei einer typischen Anwendung aber auch zahlreiche Klassen beteiligt, die Akteure oder
Ereignisse aus der Welt des Computers repräsentieren (z. B. Bildschirmfenster, Ereignisse, Lauf-
zeitfehler).

4.1.2 Strukturierte Programmierung und OOP


In vielen älteren Programmiersprachen (z. B. C, Fortran, Pascal) sind zur Strukturierung von Pro-
grammen zwei Techniken verfügbar, die in weiterentwickelter Form auch bei der OOP genutzt
werden:
• Unterprogramme
Man zerlegt ein Gesamtproblem in mehrere Teilprobleme, die jeweils in einem eigenen Un-
terprogramm gelöst werden. Wird die von einem Unterprogramm erbrachte Leistung wie-
derholt (an verschiedenen Stellen eines Programms) benötigt, muss jeweils nur ein Aufruf
mit dem Namen des Unterprogramms und passenden Parametern eingefügt werden. Durch
diese Strukturierung ergeben sich kompakte und übersichtliche Programme, die leicht er-
stellt, analysiert, korrigiert und erweitert werden können. Praktisch alle älteren Program-
miersprachen unterstützen solche Unterprogramme (Subroutinen, Funktionen, Prozeduren),
und meist stehen umfangreiche Bibliotheken mit fertigen Unterprogrammen für diverse
Standardaufgaben zur Verfügung. Beim Einsatz einer Unterprogrammsammlung klassischer
Art muss der Programmierer passende Daten bereitstellen, auf die dann vorgefertigte Routi-
nen losgelassen werden. Der Programmierer hat also seine Daten und das Arsenal der ver-
fügbaren Unterprogramme (aus fremden Quellen oder selbst erstellt) zu verwalten und zu
koordinieren.

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.

4.1.3 Auf-Bruch zu echter Klasse


In den Beispielprogrammen von Kapitel 3 wurde mit der Klassendefinition lediglich eine in Java
unausweichliche formale Anforderung an Programme erfüllt. Die im Abschnitt 1.1 vorgestellte
Klasse Bruch realisiert hingegen wichtige Prinzipien der objektorientierten Programmierung. Diese
Klasse wird nun wieder aufgegriffen und in verschiedenen Varianten bzw. Ausbaustufen als Bei-
spiel verwendet. Auf der Klasse Bruch basierende Programme sollen Schüler beim Erlernen der
Bruchrechnung unterstützen. Eine objektorientierte Analyse der Problemstellung hat ergeben, dass
in elementaren Bruchrechnungsprogrammen lediglich eine Klasse zur Repräsentation von Brüchen
Abschnitt 4.1 Überblick, historische Wurzeln, Beispiel 203

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

public void setzeZaehler(int z) {zaehler = z;}

public boolean setzeNenner(int n) { ... }

public void setzeEtikett(String eti) {


if (eti.length() <= 40)
etikett = eti;
else
etikett = eti.substring(0, 40);
}

public int gibZaehler() {return zaehler;}

public int gibNenner() {return nenner;}

public String gibEtikett() {return etikett;}

public void kuerze() {


// größten gemeinsamen Teiler mit dem euklidischen Algorithmus bestimmen
// (performante Variante mit Modulo-Operator)
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;
}
204 Kapitel 4 Klassen und Objekte

public void addiere(Bruch b) { ... }

public void frage() { ... }

public void zeige() {


String luecke = "";
int el = etikett.length();
for (int i=1; i<=el; i++)
luecke = luecke + " ";
System.out.println(" " + luecke + " " + zaehler + "\n" +
" " + etikett + " -----\n" +
" " + luecke + " " + nenner + "\n");
}
}

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

Zwei Bemerkungen zum Kopf einer Klassendefinition:


• Die Klasse Bruch ist als public definiert, damit sie uneingeschränkt von anderen Klassen
aus beliebigen Paketen (in berechtigten Modulen) genutzt werden kann.1 Weil bei der Start-
klasse Bruchrechnung eine solche Nutzung durch andere Klassen nicht in Frage kommt,
wird hier auf den (zum Starten durch die JVM nicht erforderlichen) Zugriffsmodifikator
public verzichtet. Im Zusammenhang mit den Modulen und Paketen werden die Zugriffs-
modifikatoren für Klassen systematisch behandelt (siehe Kapitel 6).
• Klassennamen beginnen einer allgemein akzeptierten Java-Konvention folgend mit einem
Großbuchstaben. Besteht ein Name aus mehreren Wörtern (z. B. BigDecimal), dann
schreibt man der besseren Lesbarkeit wegen die Anfangsbuchstaben aller Wörter groß (Pas-
cal Casing).2 Tritt ein Akronym (z. B. HTTP) als Namensbestandteil auf, dann sollte nur der
erste Buchstabe groß geschrieben werden (z. B. HttpRequest), weil anderenfalls die Na-
mensbestandteile optisch schlecht separiert werden können. An die letztgenannte Konventi-
on halten sich aber nicht alle Entwickler.
Hinsichtlich der Dateiverwaltung ist zu beachten:
• Die Bruch-Klassendefinition muss in einer Datei namens Bruch.java gespeichert werden,
weil die Klasse als public definiert ist.
• Auch für den Quellcode der Startklasse Bruchrechnung, die nicht als public definiert ist,
sollte analog eine Datei namens Bruchrechnung.java verwendet werden. Der Java-
Compiler erlaubt auch Quellcodedateien mit mehreren Top-Level - Klassen, von denen ma-
ximal eine die Sichtbarkeit public besitzen darf. Durch die Nutzung dieser Option kann es
aber passieren, dass mehrere Quellcodedateien gleichnamige Klassen enthalten, und infol-
gedessen die Reihenfolge der Übersetzung einen inakzeptablen Einfluss auf das entstehende
Programm hat (siehe Bloch 2018, S. 115f). Daher sollte eine Java-Quellcodedatei generell
nur eine Top-Level - Klasse enthalten.
• Dateien mit Java-Quellcode benötigen auf jeden Fall die Namenserweiterung .java.

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

4.2 Instanzvariablen (Felder)


Die Instanzvariablen (Felder) einer Klasse besitzen viele Gemeinsamkeiten mit den lokalen Variab-
len, die wir im Kapitel 3 über elementare Sprachelemente ausführlich behandelt haben, doch gibt es
auch wichtige Unterschiede, die im Mittelpunkt des aktuellen Abschnitts stehen. Unsere Klasse
Bruch besitzt nach der Erweiterung um ein beschreibendes Etikett die folgenden Instanzvariablen:
• zaehler (Datentyp int)
• nenner (Datentyp int)
• etikett (Datentyp String)
Zu den beiden Feldern zaehler und nenner mit dem primitiven Datentyp int ist das Feld
etikett mit dem Referenzdatentyp String dazugekommen. Jedes nach dem Bruch-Bauplan ge-
schaffene Objekt erhält seine eigene Ausstattung mit diesen Variablen.

4.2.1 Sichtbarkeitsbereich, Existenz und Ablage im Hauptspeicher


Von den lokalen Variablen einer Methode unterscheiden sich die Instanzvariablen (Felder) einer
Klasse vor allem bei der Zuordnung (vgl. Abschnitt 3.3.4):
• lokale Variablen gehören zu einer Methode
• Instanzvariablen gehören zu einem Objekt
Daraus ergeben sich gravierende Unterschiede in Bezug auf den Sichtbarkeitsbereich (synonym:
Gültigkeitsbereich), die Lebensdauer und die Ablage im Hauptspeicher:
lokale Variable Instanzvariable
Sichtbarkeit, Eine lokale Variable ist nur in ihrer Die Instanzvariablen eines existenten
Gültigkeit eigenen Methode sichtbar (gültig). Objekts sind in einer Methode sicht-
Nach der Deklarationsanweisung bar, wenn ...
bleibt sie ansprechbar bis zur schlie- • der Zugriff erlaubt (siehe 4.2.2)
ßenden Klammer des Blocks, in dem • und eine Referenz zum Objekt
sie deklariert worden ist. Ein einge- vorhanden ist.
schachtelter Block gehört zum Sicht- Instanzvariablen werden in klassenei-
barkeitsbereich des umgebenden genen Instanzmethoden durch gleich-
Blocks. namige lokale Variablen überdeckt,
können in dieser Situation jedoch über
das vorgeschaltete Schlüsselwort this
weiter angesprochen werden (siehe
Abschnitt 4.2.4).
Lebensdauer Sie existiert nur während der Ausfüh- Für jedes neue Objekt wird ein Satz
rung der zugehörigen Methode. mit allen Instanzvariablen seiner Klas-
se erzeugt. Die Instanzvariablen exis-
tieren bis zum Ableben des Objekts.
Ein Objekt wird zur Entsorgung frei-
gegeben, sobald keine Referenz auf
das Objekt mehr im Programm vor-
handen ist.
Ablage im Sie wird auf dem sogenannten Stack Die Objekte landen mit ihren In-
Speicher (deutsch: Stapel) abgelegt. Dieses stanzvariablen in einem Bereich des
Segment des programmeigenen Spei- programmeigenen Speichers, der als
chers dient zur Durchführung von Me- Heap (deutsch: Haufen) bezeichnet
thodenaufrufen. wird.

Während die folgende main() - Methode


Abschnitt 4.2 Instanzvariablen (Felder) 207

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

Bruch@87a5cc zaehler nenner etikett


Bruchrechnung-Methode main()

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

Typname Variablenname = Ausdruck ;

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

4.2.3 Automatische Initialisierung auf den Voreinstellungswert


Während bei lokalen Variablen der Programmierer für die Initialisierung verantwortlich ist, erhalten
die Instanzvariablen eines neuen Objekts automatisch die folgenden Voreinstellungswerte, wenn
der Programmierer nicht eingreift:

Datentyp Voreinstellungswert

byte, short, int, long 0

float, double 0.0

char 0 (Unicode-Zeichennummer)

boolean false

Referenztyp null

Im Bruch-Beispiel wird nur die automatische zaehler-Initialisierung unverändert übernommen:


• Beim nenner eines Bruches wäre die Initialisierung auf 0 bedenklich, weshalb eine explizi-
te Initialisierung auf den Wert 1 vorgenommen wird.
• Wie noch näher zu erläutern sein wird, ist String in Java kein primitiver Datentyp, sondern
eine Klasse. Variablen von diesem Typ können einen Verweis auf ein Objekt aus dieser
Klasse aufnehmen. Solange kein zugeordnetes Objekt existiert, hat eine String-Instanz-
variable den automatisch vergebenen Initialisierungswert null, zeigt also auf nichts. Weil
der etikett-Wert null z. B. beim Aufruf der Bruch-Methode zeige() einen Laufzeitfeh-
ler (NullPointerException) zur Folge hätte, wird ein String-Objekt mit einer leeren Zei-
chenfolge erstellt und zur etikett-Initialisierung verwendet. Das Erzeugen des String-
Objekts erfolgt implizit (ohne new-Operator, siehe unten), indem der String-Variablen
etikett ein Zeichenfolgen-Literal zugewiesen wird.

4.2.4 Verwendung in klasseneigenen und fremden Methoden


In den Instanzmethoden einer Klasse können die Instanzvariablen des aktuellen (die Methode aus-
führenden) Objekts direkt über ihren Namen angesprochen werden, was z. B. in der Bruch-Metho-
de zeige() zu beobachten ist:
System.out.println(" " + luecke + " " + zaehler + "\n" +
" " + etikett + " -----\n" +
" " + luecke + " " + nenner + "\n");
Im Beispiel zeigt sich syntaktisch kein Unterschied zwischen dem Zugriff auf die Instanzvariablen
(zaehler, nenner, etikett) und dem Zugriff auf die lokale Variable luecke.
Gelegentlich kann es sinnvoll oder auch erforderlich sein, einem Instanzvariablennamen über das
Schlüsselwort this (vgl. Abschnitt 4.4.6.2) eine explizite Referenz auf das aktuell handelnde Objekt
voranzustellen, wobei das Schlüsselwort und der Feldname durch den Punktoperator zu trennen
sind:
Abschnitt 4.2 Instanzvariablen (Felder) 211

• Das kann optional der Klarheit halber geschehen, z. B.:


System.out.println(" " + luecke + " " + this.zaehler + "\n" +
" " + this.etikett + " -----\n" +
" " + luecke + " " + this.nenner + "\n");
• Instanzvariablen werden durch gleichnamige lokale Variablen oder Methodenparameter
(siehe Abschnitt 4.3) überdeckt, können jedoch in dieser (besser zu vermeidenden) Situation
über das vorgeschaltete Schlüsselwort this weiter angesprochen werden.
Beim Zugriff auf eine Instanzvariable eines anderen Objekts derselben Klasse muss dem Variab-
lennamen eine Referenz auf das Objekt vorangestellt werden, wobei die Bezeichner (für das Objekt
bzw. für die Instanzvariable) durch den Punktoperator zu trennen sind. In der folgenden Anweisung
aus der Bruch-Methode addiere() greift das handelnde Objekt lesend auf die Instanzvariablen
eines anderen Bruch-Objekts zu, das über die Referenzvariable b angesprochen wird:
zaehler = zaehler*b.nenner + b.zaehler*nenner;
In einer statischen Methode der eigenen Klasse muss zum Zugriff auf eine Instanzvariable eines
konkreten Objekts natürlich eine Referenz auf dieses Objekt vorhanden sein und dem Instanzvariab-
lennamen vorangestellt werden (getrennt durch den Punktoperator).
Direkte Zugriffe auf die Instanzvariablen eines Objekts in Methoden fremder Klassen sind zwar
nicht grundsätzlich verboten, verstoßen aber gegen das Prinzip der Datenkapselung, das in der OOP
von zentraler Bedeutung ist. Würden die Bruch-Instanzvariablen ohne den Modifikator private
deklariert, dann könnten der Zähler und der Nenner eines Bruches in der main() - Methode der
Klasse Bruchrechnung, die sich im selben (nämlich dem unbenannten) Paket befindet, direkt an-
gesprochen werden, z. B.:
Quellcode Ausgabe
class Bruchrechnung { Exception in thread "main"
public static void main(String[] args) { java.lang.ArithmeticException: / by zero
Bruch b = new Bruch(); at Bruch.kuerze(Bruch.java:39)
b.zaehler = 1; at Bruchrechnung.main(Bruchrechnung.java:6)
b.nenner = 0;
b.kuerze();
}
}

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

4.2.5 Finalisierte Instanzvariablen


Neben der Schutzstufenwahl gibt es weitere Anlässe für den Einsatz von Modifikatoren in einer
Felddeklaration. Mit dem Modifikator final können nicht nur lokale Variablen als finalisiert dekla-
riert werden (siehe Abschnitt 3.3.10), sondern auch Instanzvariablen. Infolgedessen verhindert der
Compiler, dass nach der Initialisierung eine weitere Wertzuweisung stattfindet. So wird verhindert,
dass ein als fixiert geplanter Wert versehentlich (z. B. aufgrund eines Tippfehlers) doch geändert
wird. Dank final-Deklaration kann der Compiler Regelverstöße verhindern, die ansonsten als gra-
vierende Logikfehler großen Ärger beim Kunden und großen Aufwand beim Software-Hersteller
verursachen würden (Simons 2004, S. 60).
Während normale Felder automatisch mit der typspezifischen Null initialisiert werden (siehe Ab-
schnitt 4.2.3), ist bei finalisierten Feldern eine explizite Initialisierung erforderlich. Diese darf bei
der Deklaration (siehe Abschnitt 4.2.2), in einem Konstruktor (siehe Abschnitt 4.4.3) oder in einem
Instanzinitialisierer (siehe Abschnitt 4.4.4) erfolgen.
In unserer Klasse Bruch könnten wir für eine fortlaufende Nummerierung der im Programmablauf
erzeugten Objekte sorgen und in einer Instanzvariablen die individuelle Nummer aufbewahren. Bei
einer finalisierten Instanzvariablen ist keine irrtümliche Wertänderung zu befürchten, sodass even-
tuell eine public-Deklaration wie im folgenden Beispiel in Frage kommt:
public final int nummer;
Für die obligatorische initiale Wertzuweisung kann z. B. in den Konstruktoren der Klasse gesorgt
werden:
public Bruch() {nummer = ++anzahl;}
Diese Konstruktor-Definition greift dem Kursverlauf in doppelter Weise vor:
• Wir haben die Konstruktoren einer Klasse noch nicht behandelt (siehe Abschnitt 4.4.3).
• In der Anweisung dieses Konstruktors wird das statische Feld anzahl der Klasse Bruch
benutzt, das erst im Abschnitt 4.5.1 in die Bruch-Definition eingebaut wird, um die Anzahl
der bisher erzeugten Objekte festzuhalten.
Komplett unveränderliche Klassen (z. B. realisiert durch die durch Finalisierung aller Felder) spie-
len in der Software-Entwicklung schon lange eine wichtige Rolle und gewinnen im Zusammenhang
mit dem neuerdings populären Paradigma der funktionalen Programmierung an Bedeutung (siehe
Kapitel 12). Code mit vielen veränderlichen Variablen ist fehleranfällig, relativ schwer zu verstehen
und schlecht zu parallelisieren, d .h. auf mehrere Prozessorkerne zu verteilen. Unveränderliche
Klassen im Java-API sind z. B.:
Abschnitt 4.3 Instanzmethoden 213

• 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).

4.3.1.2 Rückgabewert und return-Anweisung


Per Rückgabewert kann eine Methode auf elegante Weise Informationen an ihren Aufrufer übermit-
teln. Man ist auf einen einzigen Wert beschränkt, hat aber beim Datentyp die freie Wahl, sodass
auch ein komplexes Informationsobjekt geliefert werden kann.
Wir haben schon im Abschnitt 3.5.2 gelernt, dass ein Methodenaufruf einen Ausdruck darstellt und
als Argument von komplexeren Ausdrücken oder von Methodenaufrufen verwendet werden darf,
sofern die Methode einen Wert von passendem Typ abliefert.
Bei der Definition einer Methode muss festgelegt werden, von welchem Datentyp ihr Rückgabewert
ist. Erfolgt keine Rückgabe, ist der Ersatztyp void anzugeben.
Als Beispiel betrachten wir die Bruch-Methode setzeNenner(), die den Aufrufer durch einen
Rückgabewert vom Datentyp boolean darüber informiert, ob sein Auftrag ausgeführt wurde (true)
oder nicht (false):1
public boolean setzeNenner(int n) {
if (n != 0) {
nenner = n;
return true;
} else
return false;
}

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

Um ein Beispiel für die return-Anweisung ohne Rückgabewert in der Bruch-Klassendefinition


unterzubringen, könnten wir in der Methode 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;
}
auf die if-else - Fallunterscheidung verzichten und stattdessen in einer if-Anweisung beim Zähler-
wert 0 die Methode vorzeitig verlassen:
public void kuerze() {
if (zaehler == 0) {
nenner = 1;
return;
}
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;
}

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.

4.3.1.4.1 Formalparameter mit einem primitiven Datentyp


Über einen Parameter mit einem primitiven Datentyp werden Informationen in eine Methode ko-
piert, um diese mit Daten zu versorgen und/oder ihre Arbeitsweise zu steuern. Als Beispiel betrach-
ten wir die folgende Variante der Bruch-Methode addiere(). Das beauftragte Objekt soll den als
Paar von Zähler und Nenner (Parameter z und n) übergebenen Bruch zu seinem eigenen Wert ad-
dieren und optional (Parameter autokurz) das Resultat gleich kürzen:
Abschnitt 4.3 Instanzmethoden 219

public boolean addiere(int z, int n, boolean autokurz) {


if (n != 0) {
zaehler = zaehler*n + z*nenner;
nenner = nenner*n;
if (autokurz)
kuerze();
return true;
} else
return false;
}

Methodeninterne Änderungen bei den über Formalparameternamen ansprechbaren lokalen Variab-


len bleiben ohne Effekt auf eine als Aktualparameter fungierende Variable der rufenden Methode.
Im folgenden Beispiel übersteht die lokale Variable imain der Methode main() den Einsatz als
Aktualparameter beim Aufruf der Instanzmethode demoPrimPar() ohne Folgen:
Quellcode Ausgabe
class Prog { 4712
void demoPrimPar (int ipar) { 4711
System.out.println(++ipar);
}

public static void main(String[] args) {


int imain = 4711;
Prog p = new Prog();
p.demoPrimPar(imain);
System.out.println(imain);
}
}

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).

4.3.1.4.2 Formalparameter mit Referenztyp


Wir haben schon festgehalten, dass die Formalparameter einer Methode wie lokale Variablen funk-
tionieren, die mit den Werten der Aktualparameter initialisiert worden sind. Methodeninterne Ände-
rungen bei den Werten dieser lokalen Variablen wirken sich nicht auf die als Aktualparameter ver-
wendeten Variablen der rufenden Methode aus. Auch bei einem Parameter mit Referenztyp (ab jetzt
kurz als Referenzparameter bezeichnet) wird der Wert des Aktualparameters (eine Objektadresse)
beim Methodenaufruf in eine lokale Variable kopiert. Dabei wird aber keinesfalls eine Kopie des
referenzierten Objekts (auf dem Heap) erstellt. Vielmehr greift die aufgerufene Methode über ihre
lokale Referenzvariable auf das Originalobjekt zu und kann dort durch den Zugriff auf Instanzvari-
ablen oder durch Methodenaufrufe Veränderungen vornehmen, sofern sie dazu berechtigt ist.
Die ältere Version der Bruch-Methode addiere() verfügt über einen Referenzparameter mit
dem Datentyp Bruch:
220 Kapitel 4 Klassen und Objekte

public void addiere(Bruch b) {


zaehler = zaehler*b.nenner + b.zaehler*nenner;
nenner = nenner*b.nenner;
this.kuerze();
}
Durch einen Aufruf dieser Methode wird ein Bruch-Objekt beauftragt, den via Referenzparameter
spezifizierten Bruch zu seinem eigenen Wert zu addieren (und das Resultat gleich zu kürzen). Zäh-
ler und Nenner des fremden Bruch-Objekts können per Referenzparameter und Punktoperator trotz
Schutzstufe private direkt angesprochen werden, weil der Zugriff in einer Methode der Klasse
Bruch stattfindet.
Dass in einer Bruch-Methodendefinition ein Referenzparameter vom Typ Bruch verwendet wird,
ist übrigens weder „zirkulär“ noch ungewöhnlich. Es ist vielmehr unvermeidlich, wenn Bruch-
Objekte miteinander kooperieren sollen.
Beim Aufruf der Methode addiere() bleibt das per Referenzparameter ansprechbare Objekt un-
verändert. Sofern entsprechende Zugriffsrechte vorliegen, was bei einem Referenzparameter vom
Typ der eigenen Klasse stets der Fall ist, kann eine Methode das Referenzparameterobjekt aber
auch verändern. Wir erweitern unsere Klasse Bruch um eine Methode namens dupliziere(), die
ein Objekt beauftragt, die Werte seiner Instanzvariablen auf ein anderes Bruch-Objekt zu übertra-
gen, das per Referenzparameter bestimmt wird:
public void dupliziere(Bruch bc) {
bc.zaehler = zaehler;
bc.nenner = nenner;
bc.etikett = etikett;
}
Hier liegt kein Verstoß gegen das Prinzip der Datenkapselung vor, weil der Zugriff auf die In-
stanzvariablen des Parameterobjekts durch eine klasseneigene Methode erfolgt, die vom Klassende-
signer sorgfältig konzipiert sein sollte.
Im folgenden Programm wird das Bruch-Objekt b1 beauftragt, die dupliziere() - Methode
auszuführen, wobei als Parameter eine Referenz auf das Objekt b2 übergeben wird:
Quellcode Ausgabe
class Bruchrechnung { 1
public static void main(String[] args) { b1 = -----
Bruch b1 = new Bruch(), b2 = new Bruch(); 2
b1.setzeZaehler(1); b1.setzeNenner(2);
b1.setzeEtikett("b1 = "); 5
b2.setzeZaehler(5); b2.setzeNenner(6); b2 = -----
b2.setzeEtikett("b2 = "); 6
b1.zeige();
b2.zeige(); b2 nach dupliziere():
b1.dupliziere(b2);
System.out.println("b2 nach dupliziere():\n"); 1
b2.zeige(); b1 = -----
} 2
}

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

Bruch[] ba = {b2, b3, b4};


b1.addiere(ba);

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).

4.3.2 Methodenaufruf und Aktualparameter


Beim Aufruf einer Instanzmethode, z. B.:
b1.zeige();
wird nach objektorientierter Denkweise eine Botschaft an ein Objekt geschickt:
„b1, zeige dich!“
Abschnitt 4.3 Instanzmethoden 223

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);

Im Beispiel wird die logistische Funktion


ex
f( x) :=
1 + ex
224 Kapitel 4 Klassen und Objekte

mit dem Graphen


1

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.:

So kann man lästiges Nachschlagen und Tippfehler vermeiden.


Abschnitt 4.3 Instanzmethoden 225

Unsere Entwicklungsumgebung zeigt bei manchen Methodenaufrufen für manche Parameter den
Namen an, z. B.:

Dieses für Programmiereinsteiger eventuell irritierende Verhalten lässt sich nach


File > Settings > Editor > Inlay Hints > Java
ein- bzw. ausschalten sowie konfigurieren:

4.3.3 Debug-Einsichten zu (verschachtelten) Methodenaufrufen


Verschachtelte Methodenaufrufe stellen keine Besonderheit dar, sondern den selbstverständlichen
Normalfall. Anhand der folgenden Bruchrechnungsstartklasse
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);
b1.zeige();
}
}
soll mit Hilfe unserer Entwicklungsumgebung IntelliJ IDEA untersucht werden, was bei folgender
Aufrufverschachtelung geschieht:
• Die statische Methode main() der Klasse Bruchrechnung ruft die Bruch-Instanzmethode
addiere() auf.
• Die Bruch-Instanzmethode addiere() ruft die Bruch-Instanzmethode kuerze() auf.
226 Kapitel 4 Klassen und Objekte

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:

Setzen Sie weitere Unterbrechungspunkte …


• in der Methode main() vor dem zeige() - Aufruf,
• in der Bruch-Methode addiere() vor dem kuerze() - Aufruf,
• in der Bruch-Methode kuerze() vor der Anweisung
ggt = divisor;
im Block der do-while - Schleife.
Starten Sie die Klasse Bruchrechnung im Debug-Modus mit der Tastenkombination
Umschalt + F9
oder mit dem Menübefehl
Run > Debug
Abschnitt 4.3 Instanzmethoden 227

oder mit dem Schalter in der Symbolleiste.


Das nun erscheinende Debug-Fenster zeigt auf der Registerkarte Debugger links die Stack-
Frames des ausgeführten Programms. Bei Erreichen des ersten Unterbrechungspunkts (Anweisung
b1.addiere(b2); in main()) ist nur der Stack Frame der Methode main() vorhanden:

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():

Über den Schalter des Debug-Fensters oder die Tastenkombination


Umschalt + Strg + F8
öffnet man das Breakpoints-Fenster zur Verwaltung der Unterbrechungspunkte:1

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.

4.3.4 Methoden überladen


Die beiden im Abschnitt 4.3.1.4 vorgestellten addiere() - Varianten können problemlos in der
Bruch-Klassendefinition miteinander und mit der originalen addiere() - Variante koexistieren,
weil die drei Methoden unterschiedliche Parameterlisten besitzen. Besitzt eine Klasse mehrere Me-
thoden mit demselben Namen, dann liegt eine sogenannte Überladung vor.
Eine Überladung ist erlaubt, wenn sich die Signaturen der beteiligten Methoden unterscheiden.
Zwei Methoden besitzen genau dann dieselbe Signatur, was innerhalb einer Klasse verboten ist,
wenn die beiden folgenden Bedingungen erfüllt sind:1
• Die Namen der Methoden sind identisch.
• Die Formalparameterlisten sind gleich lang, und die Typen korrespondierender Parameter
stimmen überein.
Für die Signatur einer Methode sind irrelevant:
• Modifikatoren
Insbesondere ändert der Modifikator static (also die Zuordnung der Methode zur Klasse,
siehe Abschnitt 4.5.3) nichts an der Signatur.
• Rückgabetyp
Die fehlende Signaturrelevanz des Rückgabetyps resultiert daraus, dass der Rückgabewert
einer Methode in Anweisungen oft keine Rolle spielt (ignoriert wird). Folglich muss unab-
hängig vom Rückgabetyp entscheidbar sein, welche Methode aus einer Überladungsfamilie
zu verwenden ist.
• Die Namen der Formalparameter

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

• Für eine Methode sollen unterschiedlich umfangreiche Parameterlisten angeboten werden,


sodass zwischen einer bequem aufrufbaren Standardausführung (mit einer möglichst kurzen
oder leeren Parameterliste) und einer Variante mit mehr Optionen gewählt werden kann. So
beherrscht z. B. die Klasse String zwei Instanzmethoden namens substring(), die eine Teil-
zeichenfolge als neues String-Objekt liefern. Während die erste Überladung nur einen Pa-
rameter für den Startindex der Teilzeichenfolge besitzt, verfügt die zweite Überladung über
einen zusätzlichen Parameter für den Endindex:
public String substring(int beginIndex)
public String substring(int beginIndex, int endIndex)

4.4 Objekte
Im aktuellen Abschnitt geht es darum, wie Objekte erzeugt, genutzt und im obsoleten Zustand wie-
der aus dem Speicher entfernt werden.

4.4.1 Referenzvariablen deklarieren


Um irgendein Objekt aus der Klasse Bruch ansprechen zu können, benötigen wir eine Referenzva-
riable mit dem Datentyp Bruch. In der folgenden Anweisung wird eine solche Referenzvariable
definiert und auch gleich initialisiert:
Bruch b = new Bruch();
Um die Wirkungsweise dieser Anweisung Schritt für Schritt zu untersuchen, beginnen wir mit einer
einfacheren Variante ohne Initialisierung:
Bruch b;
Hier wird die Referenzvariable b mit dem Datentyp Bruch deklariert, der man folgende Werte zu-
weisen kann:
• die Adresse eines Bruch-Objekts
In der Variablen wird kein komplettes Bruch-Objekt mit sämtlichen Instanzvariablen abge-
legt, sondern ein Verweis (eine Referenz) auf einen Ort im Heap-Bereich des programmei-
genen Speichers, an dem sich ein Bruch-Objekt befindet.1
• null
Wird einer Variablen dieses Referenzliteral zugewiesen, dann ist die Variable nicht undefi-
niert, sondern zeigt explizit auf nichts (vgl. Abschnitt 3.3.11.6).
Wir nehmen nunmehr offiziell und endgültig zur Kenntnis, dass Klassen als Datentypen verwendet
werden können und haben damit bislang in Java-Programmen die folgenden Datentypen zur Verfü-
gung:
• Primitive Typen (boolean, char, byte, double, ...)
• Klassen (Referenztypen)
Es kommen Klassen aus dem Java-API, aus anderen Bibliotheken und selbst definierte
Klassen in Frage. Ist eine Variable vom Typ einer Klasse, dann kann sie (neben null) die
Adresse eines Objekts aus dieser Klasse oder aus einer daraus abgeleiteten Klasse aufneh-
men.

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

4.4.2 Objekte erzeugen


Damit z. B. der folgendermaßen deklarierten Referenzvariablen b vom Datentyp Bruch
Bruch b;
ein Verweis auf ein Bruch-Objekt zugewiesen werden kann, muss ein solches Objekt erst erzeugt
werden, was per new-Operator geschieht, z. B. im folgenden Ausdruck:
new Bruch()
Als Operanden erwartet der new-Operator einen Klassennamen, dem eine Parameterliste zu folgen
hat, weil der new-Operand als Name eines Konstruktors (siehe Abschnitt 4.4.3) fungiert. Als Wert
des Ausdrucks resultiert eine Referenz (Speicheradresse), die (im Rahmen bestehender Rechte) ei-
nen Zugriff auf das neue Objekt (seine Instanzvariablen und -methoden) ermöglicht.
In der main() - Methode der folgenden Startklasse
class Bruchrechnung {
public static void main(String[] args) {
Bruch b = new Bruch();
. . .
}
}
wird die vom new-Operator gelieferte Adresse in die lokale Referenzvariable b geschrieben. Es
resultiert die folgende Situation im programmeigenen Arbeitsspeicher:1
Stack

Referenzvariable b

Adresse des Bruch-Objekts

Heap

Bruch-Objekt

zaehler nenner etikett

0 1 ""

Während lokale Variablen (während der Methodenausführung) im Stack-Bereich des programmei-


genen Arbeitsspeichers abgelegt werden, entstehen Objekte mit ihren Instanzvariablen auf dem He-
ap.
In einem Programm können mehrere Referenzvariablen auf dasselbe Objekt zeigen, z. B.:

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

Zugriffs- Formal- Serienformal-


Modifikator Klassenname ( parameter , parameter ) { }

Anweisung
,

Hinsichtlich der (Serien-)Formalparameter bestehen keine Unterscheide zwischen einem Konstruk-


tor und einer Methode (siehe Abschnitt 4.3.1).
Bei den öffentlich zugänglichen Konstruktoren ist darauf zu achten, dass die Datenkapselung nicht
ausgehebelt wird, indem Instanzvariablen auf beliebige Werte gesetzt und somit defekte Objekte
erzeugt werden können.
Die folgende Variante unserer Klasse Bruch enthält einen expliziten Konstruktor mit Parametern
zur Initialisierung aller Instanzvariablen und einen zusätzlichen, parameterfreien Konstruktor mit
leerem Anweisungsteil. Beide sind aufgrund der Schutzstufe public allgemein verwendbar:
public class Bruch {
private int zaehler;
private int nenner = 1;
private String etikett = "";

public Bruch(int z, int n, String eti) {


setzeZaehler(z);
setzeNenner(n);
setzeEtikett(eti);
}

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

Compiled from "Prog.java"


class Prog {
int ivar;

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();
}

public static void main(String[] args) {


Prog p = new Prog();
System.out.println(p.alter);
}
}
Der gint() - Aufruf ist auch in einer einfachen Variablendeklaration mit Initialisierung möglich:
private int alter = Simput.gint();
In einem Instanzinitialisierer ist aber (wie in einem Konstruktor) eine Anweisungssequenz erlaubt,
was im Beispiel zur Ausgabe einer Instruktion genutzt wird. Außerdem kann man hier Ausnahmen
abfangen und werfen, um Laufzeitfehler zu beheben oder zu melden (siehe Kapitel 11).

4.4.5 Objekte aus der Fabrik


Manche Klassen bieten statische Methoden zum Erzeugen von neuen Objekten des eigenen Typs
an, z. B. die Klasse Box im Paket javax.swing:
Box box = Box.createHorizontalBox();
Man spricht hier von Fabrikmethoden (engl.: factory methods). Bloch (2018, S. 5ff) plädiert sogar
nachdrücklich dafür, beim Design einer Klasse Fabrikmethoden gegenüber Konstruktoren zu be-
vorzugen und nennt u. a. die folgenden Vorteile von Fabrikmethoden:
• Während ein Konstruktor stets ein neues Objekt erzeugt, kann eine Fabrikmethode sich da-
für entscheiden, Aufwand sparend eine Referenz auf ein bereits vorhandenes und funktions-
gleiches Objekt zu liefern.
• Eine Fabrikmethode kann situationsgerecht ein Objekt aus einer abgeleiteten Klasse liefern.
Im Fall der Klasse Box ist ein öffentlicher Konstruktor verfügbar, sodass man das Ergebnis der vo-
rigen Anweisung auch so realisieren kann:
Box box = new Box(BoxLayout.X_AXIS);
Ein Klassendesigner hat aber auch die Option, Fabrikmethoden anzubieten und auf öffentlich zu-
gängliche Konstruktoren zu verzichten. Zu einer Klasse ohne öffentlichen Konstruktor kann aller-
dings keine abgeleitete Klasse definiert werden (siehe Abschnitt 7.3).
Im Anweisungsteil einer Fabrikmethode wird natürlich ein Objektkreationsausdruck mit new-
Operator und Konstruktor benötigt, z. B.:
public static Box createHorizontalBox() {
return new Box(BoxLayout.X_AXIS);
}

4.4.6 Objektreferenzen verwenden


Methodenparameter mit Referenztyp wurden schon im Abschnitt 4.3.1.4.2 behandelt. In diesem
Abschnitt geht es um Methodenrückgabewerte mit Referenztyp und um das Schlüsselwort this, mit
dem sich in einer Methode das aktuell handelnde Objekt ansprechen lässt.
240 Kapitel 4 Klassen und Objekte

4.4.6.1 Rückgabe mit Referenztyp


Soll ein methodenintern erzeugtes Objekt das Ende der Methodenausführung überleben, muss eine
Referenz außerhalb der Methode geschaffen werden, was z. B. über einen Rückgabewert mit Refe-
renztyp geschehen kann.
Als Beispiel erweitern wir die Klasse Bruch um die Methode klone(), welche ein Objekt beauf-
tragt, einen neuen Bruch anzulegen, mit den Werten der eigenen Instanzvariablen zu initialisieren
und die Referenz an den Aufrufer zu übergeben:
public Bruch klone() {
return new Bruch(zaehler, nenner, etikett);
}
Im folgenden Programm wird das durch b2 referenzierte Bruch-Objekt in der von b1 ausgeführten
Methode klone() erzeugt. Es ist ansprechbar und dienstbereit, nachdem der erzeugende Metho-
denaufruf längst der Vergangenheit angehört:
Quellcode Ausgabe
class Bruchrechnung { 1
public static void main(String[] args) { b1 = -----
Bruch b1 = new Bruch(1, 2, "b1 = "); 2
b1.zeige();
Bruch b2 = b1.klone(); 1
b2.zeige(); b1 = -----
} 2
}

4.4.6.2 this als Referenz auf das aktuelle Objekt


Gelegentlich ist es sinnvoll oder erforderlich, dass ein handelndes Objekt sich selbst ansprechen und
z. B. seine Adresse bei einem Methodenaufruf als Aktualparameter verwenden kann. Das ist mit
dem Schlüsselwort this möglich, das innerhalb einer Instanzmethode wie eine Referenzvariable
funktioniert. Im folgenden Beispiel ermöglicht die this-Referenz den Zugriff auf Instanzvariablen,
die von namensgleichen Formalparametern überdeckt werden:
public boolean addiere(int zaehler, int nenner, boolean autokurz) {
if (nenner != 0) {
this.zaehler = this.zaehler * nenner + zaehler * this.nenner;
this.nenner = this.nenner * nenner;
if (autokurz)
this.kuerze();
return true;
} else
return false;
}
Außerdem wird beim kuerze() - Aufruf durch die (nicht erforderliche) this-Referenz verdeutlicht,
dass die Methode vom aktuell handelnden Objekt ausgeführt wird. Später werden Sie noch weit
relevantere this-Verwendungen kennenlernen.

4.4.7 Abräumen überflüssiger Objekte durch den Garbage Collector


Stellt die Laufzeitumgebung (JVM) einen Speichermangel fest, dann tritt der Garbage Collector
(Müllsammler) in Aktion und löscht Objekte vom Heap-Speicher, die nutzlos geworden sind, weil
im Programm keine Referenz auf diese Objekte mehr vorhanden ist.
Abschnitt 4.4 Objekte 241

In unseren bisherigen Bruchrechnungs-Beispielprogrammen entsteht jedes Bruch-Objekt in der


main() - Methode der Startklasse. Beim Verlassen dieser Methode verschwindet die einzige Refe-
renz auf das Objekt, und es ist reif für den Garbage Collector. Der muss sich aber keine Mühe ge-
ben, weil das Programm mit dem Ablauf der main() - Methode ohnehin endet.
In einem größeren Programm sind die Objektlebensläufe weniger simpel und einheitlich:
• Es ist möglich (und normal), dass ein Objekt die erzeugende Methode überlebt, weil eine
Referenz nach Außen transportiert worden ist (z. B. per Rückgabewert, vgl. Abschnitt
4.4.6).
• Man kann ein Objekt während einer Methodenausführung aufgeben (dem Garbage Collector
überlassen), indem man alle Referenzen auf das Objekt aktiv entfernt. Um eine Referenz
aufzulösen, weist man der betroffenen Referenzvariablen entweder eine andere Objektrefe-
renz oder das Referenzliteral null zu, z. B.:
b1 = null;
Die explizite Beseitigung von Referenzen ist allerdings mit Aufwand verbunden und nur un-
ter speziellen Umständen empfehlenswert (siehe Bloch 2018, S. 26ff).
Vermutlich sind Programmiereinsteiger vom Garbage Collector nicht sonderlich beeindruckt.
Schließlich war im Manuskript noch nie davon die Rede, dass man sich um den belegten Speicher
nach Gebrauch kümmern müsse. Der in einer Methode von lokalen Variablen belegte Speicher wird
bei jeder Programmiersprache freigegeben, sobald die Ausführung der Methode beendet ist. Dem-
gegenüber muss der von überflüssig gewordenen Objekten belegte Speicher bei älteren Program-
miersprachen (z. B. C++) nach Gebrauch explizit wieder freigegeben werden. In Anbetracht der
Objektmengen, die manche Programme (z. B. ein Grafikeditor) benötigen, ist einiger Aufwand er-
forderlich, um eine Verschwendung von Speicherplatz zu verhindern. Mit seinem vollautomati-
schen Garbage Collector vermeidet Java lästigen Aufwand und zwei kritische Fehlerquellen:
• Weil der Programmierer keine Verpflichtung (und Berechtigung) zum Entsorgen von Objek-
te hat, kann es nicht zu Programmabstürzen durch den Zugriff auf voreilig zerstörte Objekte
kommen.
• Es entstehen keine Speicherlöcher (engl.: memory leaks) durch versäumte Speicherfreiga-
ben bei überflüssig gewordenen Objekten.
Der Garbage Collector (ab jetzt verwendete Kurzbezeichnung: GC) wird im Normalfall nur dann
tätig, wenn die JVM Speicher benötigt, sodass der genaue Zeitpunkt für die Entsorgung eines Ob-
jekts kaum vorhersehbar ist.
Bei großen Anwendungen können die im Hintergrund ablaufenden GC-Aktivitäten zu spürbaren
temporären Leistungseinbußen führen, sodass Programmierer über Maßnahmen zur GC-
Optimierung nachdenken müssen.1 Allerdings ist nicht für jeden „Hänger“ der GC verantwortlich.
Als alternative Ursachen kommen z. B. in Frage: Langsame Zugriffe auf externe Ressourcen
(Netzwerk, Datenbankserver), gegenseitige Behinderung von Threads, systemseitiger Speicherman-
gel mit der Notwendigkeit zu Festplattenzugriffen.

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

4.4.8 finalize() und Cleaner


Im Zusammenhang mit obsolet gewordenen Objekten ist die (vom Garbage Collector automatisch
erledigte) Freigabe von Speicher nicht die einzige Aufgabe. Objekte mancher Klassen verwenden
knappe Ressourcen (z. B. Datei-, Netzwerk oder Datenbankverbindungen, Threads), die möglichst
schnell zurückgegeben werden sollten, um eine Behinderung anderer Interessenten zu vermeiden. In
diesen Fällen sollte eine Methode namens close() implementiert werden, die bei jedem obsolet ge-
wordenen Objekt aufzurufen ist, um die belegten Ressourcen freizugeben (siehe z. B. Abschnitt
11.10).
Java bietet seit der Version 1 dem Klassendesigner die Möglichkeit, die beim Ableben eines Ob-
jekts erforderlichen Aufräumarbeiten in einer Methode namens finalize() unterzubringen, die ggf.
vom Garbage Collector aufgerufen wird. Der potentielle Nutzen dieser Methode besteht darin, dass
die Aufräumarbeiten nicht vom expliziten Aufruf der Methode close() abhängen, den ein nachlässi-
ger Programmierer möglicherweise vergisst. Allerdings haben gravierende Probleme dazu geführt,
dass die Methode finalize() seit Java 9 als abgewertet (engl.: deprecated) deklariert ist:1
• Es ist nicht vorhersehbar, ob und wann finalize() aufgerufen wird, sodass per finalize()
nicht für die korrekte Funktion einer Klasse gesorgt, sondern lediglich die Wahrscheinlich-
keit für einen Schaden durch die fehlerhafte Anwendung der Klasse reduziert werden kann.
• Durch die Existenz der Methode finalize() steigt der Zeitaufwand bei der Erstellung und bei
der Entsorgung eines Objekts drastisch an.
• Ein in finalize() auftretender Ausnahmefehler bleibt unentdeckt.
Weil die Methode finalize() in der Standardbibliothek noch weit verbreitet ist, soll ihre Verwen-
dung mit der folgenden, vorübergehend in die Klasse Bruch aufgenommenen Methodendefinition
demonstriert werden:
protected void finalize() throws Throwable {
super.finalize();
System.out.println(this + " finalisiert");
}
Hier tauchen einige Bestandteile auf, die bald ausführlich zur Sprache kommen und hier ohne gro-
ßes Grübeln hingenommen werden sollten:
• protected
In der Klasse Object ist für finalize() die Schutzstufe protected festgelegt (abgeleitete
Klassen sind zugriffsberechtigt, siehe unten), und dieser Zugriffsschutz darf beim Über-
schreiben der Methode in einer (implizit von Object) abgeleiteten Klasse nicht verschärft
werden. Die ohne Angabe eines Modifikators eingestellte Schutzstufe Paket enthält gegen-
über protected eine Einschränkung und ist damit hier unzulässig.
• throws Throwable
Die finalize() - Methode der Klasse Object löst ggf. eine Ausnahme aus der Klasse Thro-
wable aus (siehe Kapitel 11). Diese muss von der eigenen finalize() - Implementierung
beim Aufruf der Basisklassenvariante entweder abgefangen oder weitergereicht werden, was
durch den Zusatz throws Throwable im Methodenkopf anzumelden ist.

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 Klassenvariablen und -methoden


Neben den Instanzvariablen und -methoden unterstützt Java auch klassenbezogene Varianten. Syn-
taktisch werden diese Mitglieder in der Deklaration bzw. Definition durch den Modifikator static
gekennzeichnet, und man spricht oft von statischen Feldern bzw. Methoden. Ansonsten gibt es bei
der der Deklaration bzw. Definition kaum Unterschiede zwischen einem Instanzmitglied und dem
analogen statischen Mitglied.
Bei den statischen Mitgliedern gilt (wie bei Instanzmitgliedern) für den Zugriffsschutz:
• Per Voreinstellung ist der Zugriff allen Klassen im selben Paket erlaubt.
• Mit einem Modifikator lassen sich alternative Schutzstufen wählen, z. B.:
o private
Alle fremden Klassen werden ausgeschlossen.
o public
Alle Klassen aus Paketen in berechtigten Modulen dürfen zugreifen.2

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 = "";

private static int anzahl;


public Bruch(int z, int n, String eti) {
setzeZaehler(z);
setzeNenner(n);
setzeEtikett(eti);
anzahl++;
}

public Bruch() {anzahl++;}

. . .
}

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

Deklaration Ohne Modifikator static Mit Modifikator static

Jedes Objekt besitzt einen eigenen Klassenbezogene Variablen sind nur


Zuordnung
Satz mit allen Instanzvariablen. einmal vorhanden.

Instanzvariablen werden beim Erzeu- Klassenvariablen werden beim Laden


gen des Objekts angelegt und initiali- der Klasse angelegt und initialisiert.2
Existenz siert.
Sie werden ungültig, wenn das Objekt
nicht mehr referenziert ist.

4.5.2 Wiederholung zur Kategorisierung von Variablen


Mittlerweile haben wir verschiedene Variablensorten kennengelernt, wobei die Sortenbezeichnung
unterschiedlich motiviert war. Um einer möglichen Verwirrung vorzubeugen, bietet dieser Ab-
schnitt eine Zusammenfassung bzw. Wiederholung. Die folgenden Begriffe sollten Ihnen keine
Probleme mehr bereiten:
• Lokale Variablen ...
werden in Methoden deklariert,
landen auf dem Stack,
werden nicht automatisch initialisiert,
sind gültig von der Deklaration bis zum Ende des Blocks, der die Deklaration enthält.
• Instanzvariablen ...
werden außerhalb jeder Methode deklariert,
landen (als Bestandteile von Objekten) auf dem Heap,
werden (falls nicht finalisiert) automatisch mit dem typspezifischen Nullwert initialisiert,
sind verwendbar, wo eine Referenz zum Objekt vorliegt und der Zugriff erlaubt ist.

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.

4.5.4 Statische Initialisierer


Analog zur Initialisierung von Instanzvariablen durch die Instanzkonstruktoren (siehe Abschnitt
4.4.3) oder die (in der Regel nur bei anonymen Klassen verwendeten) Instanzinitialisierer (siehe
Abschnitt 4.4.4), die beim Erzeugen eines Objekts ausgeführt werden, bietet Java zur Vorbereitung
von Klassenvariablen und eventuell auch zu weiteren Maßnahmen auf Klassenebene statische Initi-
Abschnitt 4.5 Klassenvariablen und -methoden 249

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).

4.6 Rekursive Methoden


Innerhalb einer Methode darf man selbstverständlich nach Belieben andere Methoden aufrufen. Es
ist aber auch zulässig, dass eine Methode sich selbst aufruft. Solche rekursiven Aufrufe erlauben
eine elegante Lösung für ein Problem, das sich sukzessive auf stets einfachere Probleme desselben
Typs reduzieren lässt, bis man schließlich zu einem direkt lösbaren Problem gelangt.
Als Beispiel betrachten wir die Ermittlung des größten gemeinsamen Teilers (GGT) zu zwei ganzen
Zahlen, die in der Bruch-Methode kuerze() benötigt wird.1 Sie haben bereits zwei iterative (mit
einer Schleife realisierte) Varianten des euklidischen Lösungsverfahrens kennengelernt: Im Ab-
schnitt 1.1 wurde ein sehr einfacher Algorithmus benutzt, den Sie später in einer Übungsaufgabe
(siehe Seite 193) durch einen effizienteren Algorithmus (unter Verwendung des Modulo-Operators)
ersetzt haben. Im aktuellen Abschnitt betrachten wir noch einmal die effizientere Variante, wobei
zur Vereinfachung der Darstellung der GGT-Algorithmus vom restlichen Kürzungsverfahren ge-
trennt und in eine eigene (private) Methode namens ggTi() ausgelagert wird:2
private int ggTi(int a, int b) {
int rest;
do {
rest = a % b;
a = b;
b = rest;
} while (rest > 0);
return Math.abs(a);
}

public void kuerze() {


if (zaehler != 0) {
int ggt = ggTi(zaehler, nenner);
zaehler /= ggt;
nenner /= ggt;
} else
nenner = 1;
}

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

private int ggTr(int a, int b) {


int rest = a % b;
if (rest == 0)
return Math.abs(b);
else
return ggTr(b, rest);
}
Statt eine Schleife zu benutzen, arbeitet die rekursive Methode nach folgender Logik:
• Ist der Parameter a durch den Parameter b restfrei teilbar, dann ist der Betrag von b der
GGT, und der Algorithmus ist beendet:
return Math.abs(b);
• Anderenfalls wird das Problem, den GGT von a und b zu bestimmen, auf das einfachere
Problem zurückgeführt, den GGT von b und (a % b) zu bestimmen, und die Methode
ggTr() ruft sich selbst mit neuen Aktualparametern auf. Dies geschieht elegant im Aus-
druck der return-Anweisung:
return ggTr(b, rest);
Im iterativen Algorithmus wird übrigens derselbe Trick zur Reduktion des Problems verwendet,
und den zugrunde liegenden Satz der mathematischen Zahlentheorie kennen Sie schon aus der oben
erwähnten Übungsaufgabe im Abschnitt 3.9.
Wird die Methode ggTr() z. B. mit den Argumenten 10 und 6 aufgerufen, dann kommt es zu fol-
gender Aufrufverschachtelung:

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:

Ist das Problem direkt lösbar?

Ja Nein

Lösung ermitteln Rekursiver Aufruf mit einem


und an den Aufrufer melden einfacheren Problem
Lösung des einfacheren
Problems zur Lösung des
Ausgangsproblems verwenden
Lösung an den Aufrufer melden

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();
}

private void init() {


switch (op) {
case '+': lsg.addiere(b2);
break;
case '*': lsg.multipliziere(b2);
break;
}
}

1
Die betroffenen Personen mögen den Fachterminus Objekt nicht persönlich nehmen.
Abschnitt 4.7 Komposition 253

public boolean korrekt() {


Bruch temp = antwort.klone();
temp.kuerze();
if (lsg.gibZaehler() == temp.gibZaehler() &&
lsg.gibNenner() == temp.gibNenner())
return true;
else
return false;
}

public void zeige(int was) {


switch (was) {
case 1: System.out.println(" " + b1.gibZaehler() +
" " + b2.gibZaehler());
System.out.println(" ------ " + op + " -----");
System.out.println(" " + b1.gibNenner() +
" " + b2.gibNenner());
break;
case 2: lsg.zeige(); break;
case 3: antwort.zeige(); break;
}
}

public void frage() {


System.out.println("\nBerechne bitte:\n");
zeige(1);
do {
System.out.print("\nWelchen Zähler hat Dein Ergebnis: ");
antwort.setzeZaehler(Simput.gint());
} while (Simput.checkError());
System.out.println(" ------");
do {
System.out.print("Welchen Nenner hat Dein Ergebnis: ");
antwort.setzeNenner(Simput.gint());
} while (Simput.checkError());
}

public void pruefe() {


frage();
if (korrekt())
System.out.println("\n Richtig!");
else {
System.out.println("\n Falsch!");
zeige(2);
}
}

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();
}
}

Die vier Bruch-Objekte in einer Aufgabe dienen den folgenden Zwecken:


• b1 und b2 werden dem Anwender (in der Aufgabe-Methode frage()) im Rahmen einer
Aufgabenstellung vorgelegt, z. B. zum Addieren.
• In antwort landet der Lösungsversuch des Anwenders.
• In lsg steht das korrekte Ergebnis.
In der Klasse Bruch wird die Instanzmethode multipliziere() nachgerüstet, die analog zur
Methode addiere() arbeitet:
254 Kapitel 4 Klassen und Objekte

public void multipliziere(Bruch b) {


zaehler = zaehler*b.zaehler;
nenner = nenner*b.nenner;
kuerze();
}

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

Welchen Zähler hat Dein Ergebnis: 6


------
Welchen Nenner hat Dein Ergebnis: 12

Richtig!

Berechne bitte:

1 2
------ + -----
2 5

Welchen Zähler hat Dein Ergebnis: 3


------
Welchen Nenner hat Dein Ergebnis: 7

Falsch!
9
Das korrekte Ergebnis: -----
10

4.8 Mitgliedsklassen und lokale Klassen


Bisher haben wir mit Top-Level - Klassen gearbeitet, die eigenständig auf Paketebene (also nicht im
Quellcode einer übergeordneten Klasse) definiert werden. Für Klassen, die nur in einem einge-
schränkten Bereich (in einer anderen Klasse oder in einer Methode) benötigt werden, erlaubt Java
auch die Definition innerhalb einer umgebenden Klasse und sogar innerhalb einer Methode. Das hat
folgende Vorteile:
• Die Sichtbarkeit ist grundsätzlich (bei lokalen Klassen) oder optional (bei Mitgliedsklassen)
im Vergleich zu Top-Level - Klassen stark eingeschränkt. So ist z. B. eine lokale (metho-
denintern definierte) Klasse nur innerhalb der umgebenden Methode sichtbar, während eine
Top-Level - Klasse mindestens in ihrem Paket sichtbar ist.
• Mitgliedsklassen und lokale Klassen haben einen privilegierten Zugriff auf Variablen in der
Umgebung (auf private Felder bzw. lokale Variablen).
• Die zusammengehörigen Klassendefinitionen bleiben übersichtlich und wartungsfreundlich
an einem Ort konzentriert.
Abschnitt 4.8 Mitgliedsklassen und lokale Klassen 255

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).

4.8.1.1 Innere Klassen


Ein Objekt einer inneren Klasse ist grundsätzlich mit einem „Hüllen“ - Objekt der umgebenden
Klasse assoziiert. Das folgende Programm enthält die umgebende Klasse Mantel und darin die
innere Klasse Manteltasche. Eine Manteltasche ohne umgebenden Mantel ist nicht vorstellbar
bzw. erforderlich:

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);
}

Manteltasche gibLinkeTasche() {return links;}

Manteltasche erstelleTasche(int anzK) {return new Manteltasche(anzK);}

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

• Ein Objekt der inneren Klasse hat Zugriff auf:


o Statische Member der umgebenden Klasse (auch auf die privaten)
Wird ein Bezeichner der umgebenden Klasse in der inneren Klasse überdeckt, dann
lässt sich die Variante der umgebenden Klasse durch Voranstellen des Klassenna-
mens ansprechen.
o Felder und Methoden des umgebenden Objekts der äußeren Klasse (auch auf die pri-
vaten)
Wird ein Bezeichner der umgebenden Klasse in der inneren Klasse überdeckt, dann
lässt sich das umgebende Objekt über die this-Referenz mit vorangestelltem Klas-
sennamen ansprechen, z. B.:
Mantel.this.name
• Methoden und Konstruktoren der äußeren Klasse können auf alle Felder, Methoden und
Konstruktoren der inneren Klasse zugreifen (auch auf die privaten).
• Vor Java 16 waren in einer inneren Klasse keine statischen Methoden erlaubt, und statische
Felder mussten finalisiert sein. In Java 16 wurden die als Container für unveränderliche Da-
ten sehr nützlichen Record-Klassen eingeführt, die als Mitgliedsklassen implizit statisch
sind (siehe Abschnitt 5.5). Um Record-Klassen als Mitglieder von inneren Klassen definie-
ren zu können, wurde das Verbot statischer Mitglieder in inneren Klassen aufgehoben. Seit
Java 16 können also in einer inneren Klasse explizit statische Methoden, Felder und Klassen
sowie die implizit statischen Record-Klassen als Mitglieder definiert werden.1
Ergänzend zum obigen, etwas verspielten, 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 innere Klasse KeyIterator definiert, die die Schnittstelle Iterator<K> implementiert (siehe
Abschnitt 10.5).2 Ein KeyIterator-Objekt kann die Menge mit den Schlüsseln des zugehörigen
HashMap<K,V> - Objekts durchlaufen und nach Aufforderung durch die Methode next() den
nächsten Schlüssel liefern:
final class KeyIterator extends HashIterator implements Iterator<K> {
public final K next() { return nextNode().key; }
}
Weil ein KeyIterator-Objekt in einem umgebenden HashMap<K,V> - Objekt existiert, kann es in
seiner Methode next() auf dessen Instanzmethode nextNode() zugreifen. Offenbar passt das Kon-
zept der inneren Klasse hier perfekt.
Dass ein Objekt einer inneren Klasse das umgebende Objekt der äußeren Klasse kennt, hat auch die
folgenden Konsequenzen (Bloch 2018, S. 113):
• Bei der Objektkreation ist ein Zusatzaufwand (hinsichtlich Platz und Zeit) erforderlich.
• Die im inneren Objekt vorhandene Referenz kann das Abräumen des äußeren Objekts ver-
hindern und ein Speicherloch (engl.: memory leak) verursachen.
Wenn für ein Objekt einer Mitgliedsklasse die spezielle Beziehung zu einem Hüllenobjekt der um-
gebenden Klasse nicht erforderlich ist, dann sollte eine statische Mitgliedsklasse definiert werden.

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

4.8.1.2 Statische Mitgliedsklassen


Java kennt im Unterschied zu anderen Programmiersprachen keine statischen Top-Level - Klassen.
Statische Klassen in der Programmiersprache C# (siehe Baltes-Götz 2021, Abschnitt 5.6.5) ...
• haben ausschließlich statische Mitglieder,
• erlauben kein Instanziieren,
• lassen sich nicht beerben.
In Java lässt sich eine Top-Level - Klasse mit solchen Eigenschaften realisieren, indem ...
• ausschließlich statische Mitglieder definiert werden,
• das Instanziieren durch Konstruktoren mit der Schutzstufe private unterbunden wird,
• das Vererben durch den Modifikator final verhindert wird (siehe Abschnitt 7.11.2).
Ein Beispiel ist die Klasse Math aus dem API-Paket java.lang (siehe Abschnitt 4.5.3).
Der Modifikator static darf in Java nur dann auf eine Klasse angewendet werden, wenn für eine
Mitgliedsklasse zum Ausdruck gebracht werden soll, dass es keine innere Klasse ist, dass ihre Ob-
jekte also keine implizite Referenz auf ein umgebendes Objekt der äußeren Klasse besitzen. Eine
statische Mitgliedsklasse verhält sich wie eine Top-Level - Klasse, hat aber Zugriff auf alle stati-
schen Mitglieder der umgebenden Klasse (auch bei private-Deklaration) und muss einen Doppel-
namen führen. Im folgenden Beispiel werden die Klassen Mantel und Motte definiert. Während
eine Manteltasche ohne umgebenden Mantel nicht vorstellbar ist (siehe Beispiel für eine innere
Klasse im Abschnitt 4.8.1.1), kann eine Motte durchaus unabhängig von einem Mantel existieren:
Quellcode Ausgabe
class Mantel { Mantel 1536246767
private static int KCAL = 3000; mit einem Nährwert
private int randomID = (int) (Math.random()*Integer.MAX_VALUE); von 3000 kcal verspeist.

static class Motte {


Mantel mantel;
void frissMantel() {
mantel = new Mantel();
System.out.println("Mantel " + mantel.randomID +
"\nmit einem Nährwert\nvon " + Mantel.KCAL +
" kcal verspeist.");
}
}
}

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; }

public final V setValue(V newValue) {


V oldValue = value;
value = newValue;
return oldValue;
}
. . .
}

4.8.2 Lokale Klassen


In einer Methode dürfen lokale Klassen definiert werden, z. B.:
Quellcode Ausgabe
class Aussen { 6
private int instVar = 1;

void erledigeMitLokalerKlasse(int par) {


int lokVar = 2;
class LokaleKlasse {
int informiere() {
return instVar + lokVar + par;
}
}
LokaleKlasse w = new LokaleKlasse();
System.out.println(w.informiere());
}
}

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

Wichtige Eigenschaften von lokalen Klassen:


• Eine lokale Klasse kann auf die finalisierten lokalen Variablen der umgebenden Methode
zugreifen. Seit Java 8 wird nur noch die effektive Finalität vorausgesetzt. Diese besteht,
wenn nach der Initialisierung keine Wertveränderung mehr stattfindet. Der Modifikator fi-
nal ist nicht mehr erforderlich. Seit Java 8 kann eine lokale Klasse auch auf effektiv finale
Parameter der umgebenden Methode zugreifen. Weil der Zugriff auf (effektiv) finale Vari-
ablen bzw. Parameter beschränkt ist, sind natürlich nur lesende Zugriffe möglich. Unsere
Entwicklungsumgebung IntelliJ zeigt übrigens (seit der Version 2018.2) per Voreinstellung
für lokale Variablen (inkl. Parameter) an, ob sie nach der Initialisierung geändert werden,
sodass effektiv finale Variablen leicht an der fehlenden Unterstreichung zu erkennen sind.
So wird eine lokale Variable angezeigt, die keine effektive Finalität besitzt:

• 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

public void start(Stage primaryStage) {


. . .
button.setOnAction(new EventHandler< ActionEvent>() {
@Override
public void handle(ActionEvent event) {
label.setText("Hallo JavaFX");
}
});
. . .
}

4.9 Bruchrechnungsprogramm mit JavaFX-GUI


Nachdem Sie nun wesentliche Teile der objektorientierten Programmierung mit Java kennengelernt
haben, ist ein weiterer Ausblick auf die nicht mehr sehr ferne Entwicklung von Programmen mit
grafischer Benutzerschnittstelle (engl. Graphical User Interface, GUI) als Belohnung und Motivati-
onsquelle angemessen. Schließlich gilt es in diesem Manuskript auch die Erfahrung zu vermitteln,
dass man beim Programmieren Erfolg und damit Spaß haben kann.
Wir entwickeln unter Verwendung der Klasse Bruch, die schon im Abschnitt 1.1 vorgestellt wurde,
und die im Verlauf von Kapitel 4 wiederholt als Beispiel gedient hat, mit Hilfe des (per Voreinstel-
lung aktivierten) IntelliJ - Plugins JavaFX (siehe File > Settings > Plugins > Installed) und
des GUI-Designers Scene Builder (zur Installation siehe 2.5) ein Bruchkürzungsprogramm mit
grafischer Bedienoberfläche in JavaFX-Technik:

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.

4.9.1 JavaFX-Projekt mit dem OpenJDK 8 anlegen


Leider wird Java 8 vom JavaFX-Plugin in IntelliJ 2021.x nur noch partiell unterstützt, sodass z. B.
die Erstellung eines neuen JavaFX-Projekts mit dem OpenJDK 8 als SDK nicht möglich ist. Mit
Hilfe einer auf dem JavaFX-Plugin von IntelliJ 2019.2 basierenden Projektvorlage gelingt es aber
doch, JavaFX 8 - Projekte mit geringem Aufwand zu erstellen. Die Vorlage ist im folgenden Ordner
zu finden:
...\BspUeb\JavaFX\Template8
262 Kapitel 4 Klassen und Objekte

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;

public class Main extends Application {


@Override
public void start(Stage primaryStage) throws Exception{
Parent root = FXMLLoader.load(getClass().getResource("sample.fxml"));
primaryStage.setTitle("Hello World");
primaryStage.setScene(new Scene(root, 300, 275));
primaryStage.show();
}

public static void main(String[] args) {


launch(args);
}
}
Die Klassen des JavaFX-Programms befinden sich in einem (wenn auch nicht vorbildlich benann-
tes) Paket (siehe erste Zeile im Quellcode).1 Wir haben die Paketierungstechnik bisher aus didakti-
schen Gründen ausgeblendet, werden uns bald (im Kapitel 6) damit befassen. Erste Erfahrungen mit
dieser Technik kommen also jetzt durchaus gelegen.
Zum Starten des Programms wird noch eine Ausführungskonfiguration benötigt, die nach einem
Mausklick auf die Schaltfläche Add Configuration im folgenden Dialog erstellt werden kann
(vgl. Abschnitt 3.7.2.3.1):

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:

4.9.2 Bedienoberfläche bzw. FXML-Datei mit dem Scene Builder gestalten


Wir werden beim JavaFX-Einsatz meist die Möglichkeit zur deklarativen GUI-Gestaltung über ei-
nen XML-Dialekt namens FXML (JavaFX Markup Language) nutzen. Gelegentlich werden wir
die FXML-Datei mit der GUI-Deklaration manuell editieren, was unsere Entwicklungsumgebung
dank JavaFX-Plugin durch Code-Vervollständigung unterstützt. Meist werden wir den FXML-Code
bzw. die Bedienoberfläche jedoch mit Hilfe des Scene Builders gestalten. Dieser grafische GUI-
Designer wird von der Firma Gluon vertrieben (siehe Abschnitt 2.5 zur Installation).
Die verwendete Vorlage für JavaFX 8 - Projekte enthält die Datei sample.fxml,

die per Doppelklick im Editor geöffnet werden kann:

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

Über das Kontextmenü zum GridPane-Wurzelelement der Bedienoberfläche (siehe Document-


Zone am linken Fensterrand) fügen wir nacheinander vier Zeilen und eine Spalte in das Layout ein:
GridPane > Add Row Above
Add Row Above
Add Row Above
Add Row Above
Add Column Before
Wir markieren den kompletten GridPane-Container per Mausklick an der gezeigten Stelle

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

Auf die vier Zeilen des GridPane-Containers sollen verteilt werden:

Element Anteilige Höhe


TextField-Steuerelement für den Zähler 35%
Horizontale Linie für den Bruchstrich 10%
TextField-Steuerelement für den Nenner 35%
Button-Steuerelement zum Kürzen 20%
Nach der Markierung einer Zeile per Mausklick auf eine seitliche Lasche kann die Eigenschaft
Percent Height auf den gewünschten Wert gesetzt werden, z. B.:

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

• Über das Properties-Segment der Inspector-Zone die Eigenschaft Text ändern.


• Nach einem Doppelklick auf den Schalter in der Editing-Zone die Beschriftung editieren:

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,

und IntelliJ übernimmt im Editorfenster den aktuellen Stand.

4.9.3 Klasse Bruch einbinden


Aus den Benutzereingaben in die Textfelder des oben entworfenen Anwendungsfensters soll ein
Objekt unserer Klasse Bruch entstehen, das bei einem Mausklick auf den Schalter mit dem Kürzen
beauftragt wird. Leider stellen sich nun Designschwächen der Klasse Bruch heraus, die am ein-
fachsten zu umgehen sind, indem wir den Quellcode der Klasse Bruch in das aktuelle Projekt auf-
nehmen und dann modifizieren, was generell keine gute Idee ist. Kopieren Sie mit dem Windows-
Explorer (also mit den Mitteln des Betriebssystems) aus dem Ordner
…\BspUeb\Klassen und Objekte\Bruch\B3 (mit Konstruktoren)
die Datei Bruch.java in den Ordner mit den Quellcodedateien des Pakets sample:

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.

4.9.4 Controller-Klasse vervollständigen


Unser JavaFX-Projekt besitzt aufgrund der verwendeten Projektvorlage eine Klasse namens Con-
troller (siehe src-Ordner im Project-Fenster), die für die Behandlung der von Benutzer (z. B.
durch Mausklicks) ausgelösten Ereignisse zuständig ist. Aktuell sind allerdings noch keine entspre-
chenden Kompetenzen vorhanden:
package sample;

public class Controller {


}

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

<GridPane alignment="center" hgap="10" prefHeight="275.0" prefWidth="300.0"


xmlns="http://javafx.com/javafx/17" xmlns:fx="http://javafx.com/fxml"
fx:controller="sample.Controller">
. . .
</GridPane>

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

befindet sich in der Datei sample.fxml die onAction - Ereignisbehandlungsmethode


reduceFraction() als noch nicht realisierte Ankündigung. Wenn wir die Einfügemarke auf den
rot dargestellten Methodennamen setzen und dann die Tastenkombination Alt + Enter drücken
oder das Drop-Down - Menü zur roten Glühbirne öffnen, dann bietet IntelliJ an, die noch fehlende
Methode in der Klasse Controller zu erstellen:

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;

public class Controller {


private Bruch b = new Bruch();
public TextField tfZaehler;
public TextField tfNenner;

public void reduceFraction(ActionEvent actionEvent) {


b.setzeZaehler(Integer.parseInt(tfZaehler.getText()));
b.setzeNenner(Integer.parseInt(tfNenner.getText()));
b.kuerze();
tfZaehler.setText(Integer.toString(b.gibZaehler()));
tfNenner.setText(Integer.toString(b.gibNenner()));
}
}
Das in der Methode reduceFraction() benötigte Bruch-Objekt wird in der Klasse Control-
ler über eine Felddeklaration mit Initialisierung bereitgestellt. Zähler und Nenner dieses Bruch-
Objekts werden auf gewohnte Weise über die Zugriffsmethoden setzeZaehler() und
setzeNenner() der Klasse Bruch mit Werten versorgt, die bei den TextField-Objekten mit der
Methode getText() erfragt werden. Die gekürzten Werte des Bruchs wandern in umgekehrter Rich-
tung zurück zu den TextField-Objekten.
In der Methode start() der Klasse Main ist nur eine triviale Änderung erforderlich, die für eine
sinnvolle Titelzeilenbeschriftung im Anwendungsfenster sorgt:
primaryStage.setTitle("Brüche kürzen");
Das Programm ist nun funktionstüchtig:

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.

4.10 Übungsaufgaben zum Kapitel 4


1) Welche von den folgenden Aussagen sind richtig bzw. falsch?
1. Die Instanzvariablen einer Klasse werden meist als privat deklariert.
2. Durch Datenkapselung (Schutzstufe private) werden die Objekte einer Klasse darin gehin-
dert, Instanzvariablen anderer Objekte derselben Klasse zu verändern.
3. Bei einer Felddeklaration ohne Zugriffsmodifikator gilt in Java die Schutzstufe private.
4. Referenzvariablen werden automatisch mit den Wert null initialisiert.

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;
}

5) Könnten in einer Bruch-Klassendefinition die beiden folgenden addiere() - Methoden


koexistieren, die sich durch die Reihenfolge der Parameter für Zähler und Nenner des zu addieren-
den Bruchs unterscheiden?
public void addiere(int zpar, int npar) {
if (npar == 0) return;
zaehler = zaehler*npar + zpar*nenner;
nenner = nenner*npar;
kuerze();
}

public void addiere(int npar, int zpar) {


if (npar == 0) return;
zaehler = zaehler*npar + zpar*nenner;
nenner = nenner*npar;
kuerze();
}
278 Kapitel 4 Klassen und Objekte

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;}

public void kuerze() {


. . .
}
public void addiere(Bruch b) {
zaehler = zaehler*b.nenner + b.zaehler*nenner;
nenner = nenner*b.nenner;
kuerze();
} 5
public boolean frage() {
. . .
}

public void zeige() {


. . .
}
public void dupliziere(Bruch bc) { 6
bc.zaehler = zaehler;
bc.nenner = nenner;
bc.etikett = etikett;
}
public Bruch klone() { 7
return new Bruch(zaehler, nenner, etikett);
}

static public int hanz() {return anzahl;}


}
8
class Bruchrechnung {
public static void main(String[] args) {
Bruch b1 = new Bruch(890, 25, "");
b1.zeige(); b1.kuerze(); b1.zeige(); 9
}
}

neun Begriffe der objektorientierten Programmierung, und tragen Sie die Positionen in die folgende
Tabelle ein:
280 Kapitel 4 Klassen und Objekte

Begriff Pos. Begriff Pos.


Definition einer Instanzmethode
Konstruktordefinition
mit Referenzrückgabe
Deklaration einer lokalen Variablen Deklaration einer Klassenvariablen
Definition einer Instanzmethode
Objekterzeugung
mit Referenzparameter
Deklaration einer Instanzvariablen Definition einer Klassenmethode

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

• Skalarprodukt zweier Vektoren ermitteln


x  y 
Das Skalarprodukt der Vektoren x =  1  und y =  1  ist definiert durch:
 x2   y2 
x  y := x1 y1 + x2 y2
• Winkel zwischen zwei Vektoren in Grad ermitteln
Für den Kosinus des Winkels, den zwei Vektoren x und y im mathematischen Sinn (links
herum) einschließen, gilt:1
x y
cos(x, y) =
xy

(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)

Laenge von Vektor 1: 1,00


Laenge von Vektor 2: 1,41

Winkel: 45,00 Grad

Um wie viel Grad soll Vektor 2 gedreht werden: 45

Neuer Vektor 2 (0,00; 1,41)


Neuer Vektor 2 normiert (0,00; 1,00)

Summe der Vektoren (1,00; 1,00)


5 Wichtige spezielle Klassen
In diesem Kapitel werden spezielle, häufig benötigte Klassen vorgestellt:
• Arrays als Container für eine feste Anzahl von Elementen desselben Datentyps
• Klassen zur Verwaltung von Zeichenketten (String, StringBuilder, StringBuffer)
• Verpackungsklassen zur Integration primitiver Datentypen in das Java-Klassensystem
• Enumerationen mit wenigen, über Variablen ansprechbaren Werten (z. B. Wochentage)
• Record-Klassen zum Speichern von unveränderlicher Daten

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

1991 2005 1997 2057 1950

uni[0] uni[1] uni[2] uni[3] uni[4]


Beim Zugriff auf ein einzelnes Element gibt man nach dem Array-Namen den durch eckige Klam-
mern begrenzten Index an, wobei die Nummerierung mit 0 beginnt und bei n Elementen folglich
mit n - 1 endet.2
Man kann aber auch den kompletten Array ansprechen und z. B. als Aktualparameter an eine Me-
thode übergeben. Aufgrund der Anweisung
Arrays.sort(uni);
werden die Elemente des Arrays uni durch die statische Methode sort() der Klasse Arrays (im
Paket java.util) der Größe nach sortiert (vgl. Abschnitt 5.1.6), was zum folgenden Ergebnis führt:

1950 1991 1997 2005 2057

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.

5.1.1 Array-Variablen deklarieren


Im Vergleich zu der bisher bekannten Variablendeklaration (ohne Initialisierung) ist bei Array-
Variablen hinter dem Typbezeichner zusätzlich ein Paar eckiger Klammern anzugeben:1
Deklaration einer Array-Variablen

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

5.1.2 Array-Objekte erzeugen


Mit Hilfe des new-Operators erzeugt man ein Array-Objekt mit einem bestimmten Elementtyp und
einer bestimmten Anzahl von Elementen. In der folgenden Anweisung entsteht ein Array-Objekt
mit 5 int-Elementen, und seine Adresse landet in der Variablen uni:
uni = new int[5];
Im new-Operanden muss hinter dem Datentyp zwischen eckigen Klammern die Anzahl der Elemen-
te festgelegt werden, wobei ein beliebiger Ausdruck mit ganzzahligem Wert ( 0) erlaubt ist. Man
kann also die Länge eines Arrays zur Laufzeit festlegen, z. B. in Abhängigkeit von einer Benutzer-
eingabe.
Die Deklaration einer Array-Referenzvariablen und die Erstellung des Array-Objekts lassen sich
natürlich auch in einer Anweisung erledigen, z. B.:
int[] uni = new int[5];
Mit der Verweisvariablen uni und dem referenzierten Array-Objekt auf dem Heap haben wir ins-
gesamt die folgende Situation im Speicher:
Referenzvariable uni
Adresse des Array-Objekts

Heap

0 0 0 0 0

Array-Objekt mit 5 int-Elementen

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

5.1.3 Kovariante Einbindung von Arrays in die Klassenhierarchie


Um ein wichtiges Detail bei der Einbindung von Arrays in die Java-Klassenhierarchie behandeln zu
können, betrachten wir als Beispiel die folgenden Klassenhierarchie aus einem Speditionsverwal-
tungsprogramm:
• Die Klasse Nutzfahrzeug stammt direkt von Object ab:
class Nutzfahrzeug {...}
• Die Klasse Personentransporter ist aus der Klasse Nutzfahrzeug abgeleitet:
class Personentransporter extends Nutzfahrzeug {...}
• Die Klasse Taxi stammt von Personentransporter ab:
class Taxi extends Personentransporter {...}
In der folgenden Abbildung ist zu sehen, wie die drei Klassen sowie die zugehörigen Array-Klassen
Nutzfahrzeug[], Personentransporter[] und Taxi[] in die Java-Klassenhierarchie einge-
hängt sind.
Object

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

5.1.4 Arrays verwenden


Der Zugriff auf ein Element eines Array-Objekts geschieht über eine zugehörige Referenzvariable,
an deren Namen zwischen eckigen Klammern ein passender Index angehängt wird. Als Index ist ein
beliebiger Ausdruck mit einem nicht-negativen ganzzahligem Wert erlaubt, wobei natürlich die
Feldgrenzen zu beachten sind. In der folgenden for-Schleife wird pro Durchgang ein zufällig ge-
wähltes Element des int-Arrays inkrementiert,
for (int i = 0; i < drl; i++)
uni[zzg.nextInt(5)]++;
auf den die Referenzvariable uni aufgrund der im Abschnitt 5.1.2 beschriebenen Deklaration und
Initialisierung zeigt:
int[] uni = new int[5];
Den Indexwert liefert die von einem Objekt der Klasse Random (im Paket java.util) ausgeführte
Zufallszahlenmethode nextInt() mit dem Rückgabetyp int.
Wie in vielen anderen Programmiersprachen hat auch in Java das erste von n Array-Elementen die
Nummer 0 und folglich das letzte die Nummer n - 1. Damit existiert z. B. nach der Anweisung
int[] uni = new int[5];
kein Element uni[5]. Ein Zugriffsversuch führt zum Laufzeitfehler vom Typ ArrayIndexOutOf-
BoundsException, z. B.:
Exception in thread "main" java.lang.ArrayIndexOutOfBoundsException: 5
at Prog.main(Prog.java:4)
Wenn das verantwortliche Programm einen solchen Ausnahmefehler nicht behandelt (siehe Kapitel
11), dann wird es vom Laufzeitsystem beendet. Man kann sich in Java generell darauf lassen, dass
jede Überschreitung von Feldgrenzen verhindert wird, sodass es nicht zur Verletzung anderer Spei-
cherbereiche und den entsprechenden Folgen (Absturz mit Speicherschutzverletzung, unerklärliches
Programmverhalten) kommt.
Die (z. B. durch eine Benutzerentscheidung zur Laufzeit festgelegte) Länge eines Array-Objekts
lässt sich über die finalisierte und öffentliche Instanzvariable length feststellen, z. B.:
Quellcode Eingabe (fett) und Ausgabe
class Prog { Gew. Länge des Vektors: 3
public static void main(String[] args) {
System.out.print("Gew. Länge des Vektors: "); Wert von Element 0: 7
int[] wecktor = new int[Simput.gint()]; Wert von Element 1: 13
System.out.println(); Wert von Element 2: 4711

for(int i = 0; i < wecktor.length; i++) { 7


System.out.print("Wert von Element " + i + ": "); 13
wecktor[i] = Simput.gint(); 4711
}
System.out.println();

for(int i = 0; i < wecktor.length; i++)


System.out.println(wecktor[i]);
}
}
288 Kapitel 5 Wichtige spezielle Klassen

5.1.5 Array-Kopien mit neuer Länge erstellen


Existiert ein Array-Objekt erst einmal, kann die Anzahl seiner Elemente nicht mehr geändert wer-
den. Um einen Array zu „verlängern“, muss man also ...
• einen neuen, größeren Array erstellen,
• die vorhandenen Elemente dorthin kopieren
• und den alten Array dem Garbage Collector überlassen.
Unter Verwendung der statischen Methode copyOf() aus der Klasse Arrays (im Paket java.util) ist
eine solche „Verlängerung“ in einem Aufruf zu erledigen. In der Dokumentation zur API-Klasse
Arrays findet sich eine Familie von copyOf() - Überladungen für diverse Elementtypen, z. B. die
folgende Variante für den Typ int:
public static int[] copyOf(int[] original, int newLength)
Hinzugekommene Elemente werden mit dem typspezifischen Nullwert initialisiert.
Einige später vorzustellende API-Kollektionsklassen zur Verwaltung von Elementlisten gehen im
Bedarfsfall analog vor, um die Kapazität des intern zum Speichern der Elemente verwendeten Ar-
rays zu erhöhen. Im Quellcode der API-Klasse ArrayList (im Paket java.util), die wir später als
„größendynamischen“ Container mit Array-Innenleben kennenlernen werden, findet sich z. B. die
folgende private Methode grow():
private Object[] grow(int minCapacity) {
return elementData = Arrays.copyOf(elementData, newCapacity(minCapacity));
}
Ist beim copyOf() - Aufruf die angegebene neue Länge kleiner als die alte, entsteht eine durch
Streichung der Elemente mit den höchsten Indexnummern gekürzte Array-Kopie.

5.1.6 Nützliche Methoden in der Klasse Arrays


Neben der im letzten Abschnitt vorgestellten Methode copyOf() beherrscht die Klasse Arrays noch
weitere nützliche statische Methoden, z. B.:
• public static void sort(int[] iar)
Die in Überladungen für diverse Elementdatentypen vorhandene Methode sort() realisiert
das Dual-Pivot-Quicksort-Verfahren von Vladimir Yaroslavskiy, Jon Bentley und Joshua
Bloch.
• public static int binarySearch(int[] iar, int ges)
Die in Überladungen für diverse Elementdatentypen vorhandene Methode binarySearch()
durchsucht einen sortierten Array nach einem Wert bzw. Objekt unter Verwendung des Hal-
bierungsverfahrens. Sie liefert bei einer erfolgreichen Suche den Index des Treffers und
nach einer vergeblichen Suche einen negativen Wert. Bei einem mehrfach vorhandenen
Suchwert wird für die Entscheidung zwischen den zugehörigen Indexwerten keine Regel ga-
rantiert. Bei einem unsortierten Array ist das Suchergebnis undefiniert.
• public static void fill(int[] iar, int val)
Die in Überladungen für diverse Elementdatentypen vorhandene Methode fill() setzt alle
Elemente eines Arrays auf den gewünschten Wert.

5.1.7 Beispiel: Beurteilung des Java-Pseudozufallszahlengenerators


Oben wurde am Beispiel des 5-elementigen int-Arrays uni demonstriert, dass die Array-Technik
im Vergleich zur Verwendung einzelner Variablen den Aufwand bei der Deklaration und beim Zu-
griff deutlich verringert. Insbesondere bei Verwendung einer Schleife erweist sich die Ansprache
der einzelnen Array-Elemente über einen Index als überaus praktisch. Die im bisherigen Verlauf
Abschnitt 5.1 Arrays 289

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;

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


uni[zzg.nextInt(5)]++;

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]);
}
}

Die Deklarations- und Initialisierungsanweisung


int[] wecktor = {1, 2, 3};
ist äquivalent zu:
Abschnitt 5.1 Arrays 291

int[] wecktor = new int[3];


wecktor[0] = 1;
wecktor[1] = 2;
wecktor[2] = 3;
Initialisierungslisten sind nicht nur bei der Deklaration erlaubt, sondern auch bei der Objektkreation
per new-Operator, z. B.:
int[] wecktor;
wecktor = new int[] {1, 2, 3};
Es ist auch eine leere Initialisierungsliste erlaubt, wobei ein gutes Anwendungsbeispiel nicht leicht
zu finden ist, weil Länge eines Arrays bekanntlich nach der Erstellung nicht mehr geändert werden
kann.

5.1.9 Objekte als Array-Elemente


Für die Elemente eines Arrays ist natürlich auch ein Referenztyp erlaubt. Im folgenden Beispiel
wird ein Array mit Objekten aus unserer Beispielklasse Bruch erzeugt:
Quellcode Ausgabe
class Bruchrechnung { 5
public static void main(String[] args) { b2 = -----
Bruch b1 = new Bruch(1, 2, "b1 = "); 6
Bruch b2 = new Bruch(5, 6, "b2 = ");
Bruch[] bruvek = {b1, b2};
bruvek[1].zeige();
}
}

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.

5.1.10 Mehrdimensionale Arrays


In der linearen Algebra und in vielen anderen Anwendungsbereichen werden auch mehrdimensio-
nale Arrays benötigt. Ein zweidimensionaler Array wird in Java als Array of Arrays realisiert, z. B.:
Quellcode Ausgabe
class Prog { matrix.length = 4
public static void main(String[] args) { matrix[0].length = 3
int[][] matrix = new int[4][3];
1 2 3
System.out.println("matrix.length = " + matrix.length); 2 4 6
System.out.println("matrix[0].length = "+matrix[0].length+"\n"); 3 6 9
4 8 12
for(int i = 0; i < matrix.length; i++) {
for(int j = 0; j < matrix[i].length; j++) {
matrix[i][j] = (i+1)*(j+1);
System.out.print(" " + matrix[i][j]);
}
System.out.println();
}
}
}

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:

Referenzvariable matrix Stack

Adresse des Array-Objekts mit int-Array - Elementen

Heap

matrix[0] int-Array - 1 2 3 matrix[0][2]


Adresse

int-Array -
matrix[1] 2 4 6 matrix[1][2]
Adresse

matrix[2] int-Array - 3 6 9 matrix[2][2]


Adresse

int-Array -
matrix[3] 4 8 12 matrix[3][2]
Adresse

Im nächsten Beispielprogramm wird die Möglichkeit demonstriert, mehrdimensionale Arrays mit


unterschiedlich langen Elementen anzulegen, sodass z. B. eine ausgesägte (engl. jagged) Matrix
entsteht:
Abschnitt 5.2 Klassen für Zeichenfolgen 293

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();
}
}
}

5.2 Klassen für Zeichenfolgen


Das Java-API bietet für den Umgang mit Zeichenfolgen, die grundsätzlich aus Unicode-Zeichen
bestehen, mehrere, für unterschiedliche Einsatzzwecke optimierte Klassen an, die sich alle im Paket
java.lang befinden:
• String
String-Objekte können nach dem Erzeugen nicht mehr geändert werden. Momentan er-
scheinen Ihnen unveränderliche Objekte eventuell noch als eingeschränkt brauchbar. Im
weiteren Verlauf des Kurses wird aber immer öfter von den Vorteilen unveränderlicher Ob-
jekte zu hören sein (z. B. im Zusammenhang mit dem Multithreading).
• StringBuilder, StringBuffer
Für variable Zeichenfolgen sollte unbedingt die Klasse StringBuilder oder die Klasse
StringBuffer verwendet werden, weil deren Objekte nach dem Erzeugen noch verändert
werden können.
294 Kapitel 5 Wichtige spezielle Klassen

5.2.1 Die Klasse String für konstante Zeichenfolgen


Nach Einschätzung von Oaks (2014, S. 198) ist String in Java die mit Abstand am häufigsten ver-
wendete Klasse, und es sind einige Anstrengungen unternommen worden, um für eine bequeme
Verwendung sowie für eine gute Performanz zu sorgen. Man hat sich dafür entschieden, die Klasse
für den lesenden Zugriff auf Zeichenfolgen zu optimieren und die Objekte als unveränderlich zu
konzipieren.
• Der durch die Unveränderlichkeit ermöglichte interne String-Pool hilft dabei, Speicherplatz
und Rechenzeit zu sparen (siehe Abschnitt 5.2.1.3).
• Beim Multithreading sind unveränderliche Objekte generell von Vorteil, weil die gemein-
same Nutzung durch mehrere Threads ohne Synchronisierung möglich ist (siehe Kapitel 15).
Bis Java 8 hat ein String-Objekt seine Daten intern in einem char-Array gespeichert und demzu-
folge pro Zeichen 2 Bytes verwendet (Unicode-Zeichensatz mit 216 Zeichen, Codierung UTF-16).
Seit Java 9 speichert ein String-Objekt seine Daten in einem byte-Array und verwendet pro Zei-
chen entweder ein Byte oder zwei Bytes:1
• Enthält ein String-Objekt ausschließlich Zeichen, die auch im Latin-1 - Zeichensatz vor-
handen sind, dann wird pro Zeichen nur ein Byte verwendet (Latin-1 - bzw. ISO-8859-1 -
Codierung).
• Anderenfalls werden pro Zeichen zwei Bytes verwendet (UTF-16 - Codierung).
So wird Speicherplatz gespart, wobei weder vorhandene Java-Programme angepasst werden müs-
sen, noch Lernaufwand seitens der Entwickler erforderlich ist. Die Klasse String liefert somit ein
gutes Beispiel für die durch Datenkapselung ermöglichte Flexibilität bei der internen Datenablage.

5.2.1.1 Erzeugen von String-Objekten


In der folgenden Deklarations- und Initialisierungsanweisung
String s1 = "abcde";
wird:
• eine String-Referenzvariable namens s1 angelegt,
• ein neues String-Objekt auf dem Heap erzeugt, falls noch kein inhaltsgleiches dort vorhan-
den ist,
• die Adresse des neu erstellten oder bereits angetroffenen Objekts in der Referenzvariablen
abgelegt.
Soviel objektorientierten Hintergrund sieht man der angenehm einfachen Anweisung auf den ersten
Blick nicht an. In Java sind jedoch auch Zeichenkettenliterale als String-Objekte realisiert, sodass
z. B.
"abcde"
einen Ausdruck darstellt, der als Wert einen Verweis auf ein String-Objekt auf dem Heap liefert.
Die obige Anweisung erzeugt im Hauptspeicher die folgende Situation:

1
https://docs.oracle.com/javase/9/tools/java.htm
Abschnitt 5.2 Klassen für Zeichenfolgen 295

Heap

Referenzvariable s1 String-Objekt

Adresse des String-Objekts "abcde"

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");

5.2.1.2 String als WORM - Klasse


Nachdem ein String-Objekt auf dem Heap erzeugt wurde, ist es unveränderlich (engl.: immutab-
le). In der Abschnittsüberschrift wird für diesen Sachverhalt eine Abkürzung aus der Elektronik
ausgeliehen: WORM (Write Once Read Many). Eventuell werden Sie die Unveränderlichkeit von
String-Objekten in Zweifel ziehen und ein Gegenbeispiel der folgenden Art vorbringen:
Quellcode Ausgabe
class Prog { testr = abc
public static void main(String[] args) { testr = abcdef
String testr = "abc";
System.out.println("testr = " + testr);
testr = testr + "def";
System.out.println("testr = " + testr);
}
}

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.

5.2.1.3 Interner String-Pool und Identitätsvergleich


Geschieht wie im folgenden Beispiel
String s1 = "abcde";
die Initialisierung einer String-Referenzvariablen über ein Literal oder einen anderen konstanten
Ausdruck, sodass schon der Compiler die resultierende Zeichenfolge kennt, dann kommt der soge-
nannte interne String-Pool ins Spiel:
• Ist hier bereits ein inhaltsgleiches String-Objekt vorhanden, dann wird dessen Adresse in
die Referenzvariable geschrieben und auf eine Neukreation verzichtet.
• Anderenfalls wird im String-Pool ein neues Objekt angelegt und dessen Adresse in die Re-
ferenzvariable geschrieben.
296 Kapitel 5 Wichtige spezielle Klassen

So wird verhindert, dass für wiederholt im Quellcode auftretende Zeichenfolgenliterale jeweils


Speicherplatz verschwendend ein neues Objekt entsteht. Diese Vorgehensweise ist sinnvoll, weil
sich vorhandene String-Objekte garantiert nicht mehr ändern (siehe Abschnitt 5.2.1.2).
Außerdem ist für die im String-Pool registrierten Objekte garantiert, dass sie unterschiedliche Zei-
chenfolgen enthalten, was sich bald im Zusammenhang mit Identitätsvergleichen als nützlich (Re-
chenzeit sparend) herausstellen wird.
Kommt bei der Initialisierung eines String-Referenzvariablen ein Ausdruck mit Beteiligung von
Variablen zum Einsatz, dann wird auf jeden Fall ein neues Objekt erzeugt und der interne String-
Pool ist nicht beteiligt, z. B. bei der folgenden Variablen s3:
String de = "de";
String s3 = "abc" + de;
Ebenso wird auch bei Verwendung des new-Operators verfahren.
Ein String-Literal wie im folgenden Beispiel
String s1 = new String("abcde");
als Konstruktorparameter zu verwenden, ist nur selten sinnvoll, denn:
• Das Zeichenfolgenliteral führt zu einem neuen String-Objekt im internen String-Pool, falls
dort noch kein inhaltsgleiches Objekt existiert.
• Per new-Operator entsteht auf jeden Fall ein neues String-Objekt auf dem Heap, das diesel-
be Zeichenfolge enthält wie das Parameter-Objekt.
Weil die beiden String-Objekte unveränderlich sind, lohnt sich der Doppelaufwand nicht. Der
String-Konstruktor mit Zeichenkettenliteral als Parameter kann in Ausnahmefällen aber doch sinn-
voll sein, wenn unbedingt ein neues Objekt benötigt wird (z. B. als Monitorobjekt für die später zu
behandelnde Thread-Synchronisation).
Für den Vergleich von String-Variablen per Identitätsoperator haben die obigen Ausführungen
wichtige Konsequenzen, wie das folgende Programm zeigt:
Quellcode Ausgabe
class Prog { (s1 == s2) = true
public static void main(String[] args) { (s1 == s3) = false
String s1 = "abcde"; (s1 == s4) = false
String s2 = "abc" + "de";
String de = "de";
String s3 = "abc" + de;
String s4 = new String("abcde");
System.out.print("(s1 == s2) = " + (s1 == s2) + "\n"+
"(s1 == s3) = " + (s1 == s3) + "\n"+
"(s1 == s4) = " + (s1 == s4));
}
}

Das merkwürdige1 Verhalten des Programms hat folgende Ursachen:


• Wendet man den Identitätsoperator auf zwei String-Referenzvariablen an, dann werden die
in den Variablen gespeicherten Adressen verglichen, keinesfalls die Inhalte der referenzier-
ten String-Objekte.
• Nur wenn die beiden am Vergleich beteiligten String-Referenzvariablen auf Objekte im in-
ternen String-Pool zeigen, ist garantiert, dass die Variablen genau dann für dieselbe Zei-
chenfolge stehen, wenn sie denselben Referenzwert enthalten.

1
„Merkwürdig“ bedeutet hier, dass sich eine Aufnahme in das Langzeitgedächtnis lohnt.
Abschnitt 5.2 Klassen für Zeichenfolgen 297

Im Beispielprogramm werden vier String-Objekte mit folgenden Referenzen erzeugt:

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.

5.2.1.4 Methoden für String-Objekte


Von den ca. 70 öffentlichen Instanzmethoden der Klasse der String werden in diesem Abschnitt nur
die wichtigsten angesprochen. Für spezielle Anwendungen lohnt sich also ein Blick in die Doku-
mentation zur Klasse String.

5.2.1.4.1 Verketten von Strings


Zum Verketten von Strings kann in Java der „+“ - Operator verwendet werden, wobei beliebige
Datentypen bei Bedarf automatisch in Strings konvertiert werden. Im folgenden Beispiel wird mit
Klammern dafür gesorgt, dass der Compiler die „+“ - Operatoren jeweils sinnvoll interpretiert
(Verketten von String-Objekten bzw. Addieren von Zahlen):
Quellcode Ausgabe
class Prog { 4 + 3 = 7
public static void main(String[] args) {
System.out.println("4 + 3 = " + (4 + 3));
}
}

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));
}
}

Für die Methode compareTo() sind folgende int-Rückgabewerte garantiert:


compareTo() - Rückgabe
kleiner negative Zahl
Die Sortierungspriorität des angesprochenen String-
gleich 0
Objekts ist im Vergleich zum Parameterobjekt:
größer positive Zahl

5.2.1.4.4 Länge einer Zeichenkette


Während bei Array-Objekten die Anzahl der Elemente in der finalisierten Instanzvariablen length
zu finden ist (vgl. Abschnitt 5.1), lässt sich die Länge einer Zeichenkette über die Instanzmethode
length() ermittelt:
Abschnitt 5.2 Klassen für Zeichenfolgen 299

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());
}
}

5.2.1.4.5 Zeichen(folgen) extrahieren, suchen oder ersetzen


Im folgenden Programm werden einige anschließend beschriebene String-Methoden demonstriert:
Quellcode Ausgabe
class Prog { rg
public static void main(String[] args) { 2
String bsp = "Brgl"; -1
System.out.println(bsp.substring(1, 3)); false
System.out.println(bsp.indexOf("g")); B
System.out.println(bsp.indexOf("x"));
System.out.println(bsp.startsWith("r"));
System.out.println(bsp.charAt(0));
}
}

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

c) Zeichenfolge auf eine bestimmte Startsequenz überprüfen


Mit der Methode
public boolean startsWith(String start)
lässt sich feststellen, ob ein String mit einer bestimmten Zeichenfolge beginnt.

d) Das Zeichen an einer bestimmten Position ermitteln


Weil ein String kein Array ist, kann auf die einzelnen Zeichen nicht per Indexoperator ( [ ] ) zuge-
griffen werden. Mit der String-Methode
public char charAt(int index)
steht aber ein Ersatz zur Verfügung, wobei die Nummerierung der Zeichen bei 0 beginnt. Ein Auf-
ruf mit ungültiger Position führt zu einem Ausnahmefehler aus der Klasse
300 Kapitel 5 Wichtige spezielle Klassen

java.lang.StringIndexOutOfBoundsException

e) Aus einem String einen Char - Array erstellen


Wenn auf jeden Fall mit dem Indexoperator gearbeitet werden soll, dann kann aus einem String
über die Methode
public char[] toCharArray()
ein char-Array mit identischem Inhalt erzeugt werden, z. B.:
Quellcode Ausgabe
class Prog { a
public static void main(String[] args) { b
String s = "abc"; c
char[] c = s.toCharArray();
for (int i = 0; i < c.length; i++)
System.out.println(c[i]);
}
}

f) Zeichen oder Teilzeichenfolgen ersetzen


Von der Methode
public String replace(char oldChar, char newChar)
erhält man einen neuen String, der aus dem angesprochenen Original hervorgeht, indem ein altes
Zeichen (an allen Trefferstellen) durch ein neues Zeichen ersetzt wird, z. B.:
String s2 = s1.replace('C','c');
Mit weiteren replace() - Überladungen kann man das erste Auftreten einer Teilzeichenfolge oder
alle Teilzeichenfolgen, die einem regulären Ausdruck genügen, durch eine neue Teilzeichenfolge
ersetzen lassen.

5.2.1.4.6 Groß-/Kleinschreibung normieren


Von den Methoden
public String toUpperCase()
bzw.
public String toLowerCase()
erhält man einen neuen String, der im Unterschied zum angesprochenen Original auf Groß- bzw.
Kleinschreibung normiert ist, was vor Vergleichen oft sinnvoll ist, z. B.:
Quellcode Ausgabe
class Prog { true
public static void main(String[] args) {
String a = "Otto", b = "otto";
System.out.println(a.toUpperCase().equals(b.toUpperCase()));
}
}

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.

5.2.1.5 Aufwand beim Inhalts- bzw. Referenzvergleich


Wenn sehr viele Inhaltsvergleiche vorzunehmen sind, dann ist der im Abschnitt 5.2.1.3 beschriebe-
ne interne String-Pool eine erwägenswerte Option. Zeigen zwei Referenzvariablen auf Pool-
Strings, dann folgt aus der Gleichheit der Adressen bereits die Inhaltsgleichheit. Folglich kann man
statt des relativ aufwändigen Inhaltsvergleichs den erheblich flotteren Referenzvergleich durchfüh-
ren.
Allerdings muss zunächst dafür gesorgt werden, dass die beteiligten Referenzvariablen auf Pool-
Strings zeigen. Wie bereits im Abschnitt 5.2.1.3 berichtet wurde, gibt es neben der String-
Initialisierung durch einen konstanten Ausdruck noch eine zweite Möglichkeit, ein String-Objekt
im Pool abzulegen. Man ruft dazu die String-Instanzmethode intern() auf,
public String intern()
die zum angesprochenen String seine sogenannte kanonische Repräsentation liefert:
• Ist im internen Pool ein inhaltsgleicher String vorhanden (im Sinne der equals() - Metho-
de), dann wird dessen Adresse als Rückgabe geliefert.
• Anderenfalls wird der angesprochene String in den Pool aufgenommen und seine Adresse
als Rückgabe geliefert.
Für die Planung der intern() - Verwendung ist es relevant, wie die JVM den internen String-Pool
realisiert und im Speicher ablegt (siehe Oaks 2014, S. 198ff; Vorontsov 2014). Zur Verwaltung der
Pool-Strings wird eine Hash-Tabelle, also ein Kollektionsobjekt für Schlüssel-Wert - Paare verwen-
det (analog zur generischen Klasse HashMap<K,V> aus dem Java Collections Framework, siehe
Abschnitt 10.7.2). Seit der Java-Version 7 befindet sich diese Hash-Tabelle auf dem allgemeinen
Heap, während es sich bis Java 6 in der Method Area befand. Während die Klasse
HashMap<K,V> aus dem Java Collections Framework ihre Größe dynamisch ändern kann, ist dies
bei der Hash-Tabelle zur Verwaltung des internen String-Pools nicht möglich. In aktuellen Java-
Versionen kann die Kapazität der Hash-Tabelle zum internen String-Pools allerdings beim JVM-
Start über den Parameter -XX:StringTableSize festgelegt werden. Oaks (2014, S. 200) empfiehlt,
ca. die doppelte Anzahl der anzunehmenden Pool-Strings anzugeben und dabei eine Primzahl zu
verwenden.
Eine Überschreitung der Pool-Kapazität führt zu einer verschlechterten Leistung der Methode in-
tern(). Bei Server-Anwendungen kann ein Risiko bestehen, wenn Benutzer die Kontrolle über die
Aufnahme von Strings in den internen Pool haben, und dessen Kapazität überschreiten.
Das Internieren der von zu vergleichenden Variablen referenzierten String-Objekten verursacht
zunächst Zeitaufwand, beschleunigt aber anschließende String-Vergleiche. Um einen Eindruck von
der Rentabilität des Internierens zu gewinnen, werden im folgenden Programm anz Zufallszeichen-
folgen der Länge len jeweils wdh mal mit einem zufällig gewählten Partner verglichen. Dies ge-
schieht zunächst per equals() - Methode und dann nach dem zwischenzeitlichen Internieren per
Adressenvergleich.
302 Kapitel 5 Wichtige spezielle Klassen

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];

// Zufallszeichenfolgen mit Hilfe eines StringBuiler-Objekts erzeugen


for (int i = 0; i < anz; i++) {
for (int j = 0; j < len; j++)
sb.append((char) (65 + ran.nextInt(26)));
sar[i] = sb.toString();
sb.delete(0, len);
}

long start = System.currentTimeMillis();


int hits = 0;
// Inhaltsvergleiche
for (int n = 1; n <= wdh; n++)
for (int i = 0; i < anz; i++)
if (sar[i].equals(sar[ran.nextInt(anz)]))
hits++;
System.out.println((wdh * anz)+" Inhaltsvergleiche ("+hits+
" hits) benötigen "+(System.currentTimeMillis()-start)+" Millisekunden");

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

5.2.2 Die Klassen StringBuilder und StringBuffer für veränderliche Zeichenfolgen


Für häufig zu ändernde Zeichenfolgen sollte man statt der Klasse String unbedingt die Klasse
StringBuilder oder die Klasse StringBuffer verwenden, weil hier beim Ändern einer Zeichenkette
das zeitaufwändige Erstellen eines neuen Objektes entfällt.
Als Nachteile im Vergleich zur Klasse String sind zu nennen:
• Weil die Objekte nicht unveränderlich sind, scheiden Optimierungen wie der interne String-
Pool aus.
• Es fehlt die syntaktische Unterstützung der Programmiersprache, z. B. durch den überlade-
nen „+“ - Operator.
Der einzige Unterschied zwischen den Klassen StringBuilder und StringBuffer besteht darin, dass
die Klasse StringBuffer thread-sicher ist, sodass ein Objekt dieser Klasse gefahrlos von mehreren
Threads (Ausführungsfäden, siehe Kapitel 15) eines Programms genutzt werden kann. Diese
Thread-Sicherheit ist aber mit Aufwand verbunden, sodass die Klasse StringBuilder zu bevorzugen
ist, wenn eine variable Zeichenfolge nur von einem Thread genutzt wird. Bloch (2018, S. 84) hält
die (ältere) Klasse StringBuffer generell für obsolet. Weil die beiden Klassen völlig analog aufge-
baut sind, kann sich die anschließende Beschreibung auf die Klasse StringBuilder beschränken.
In der Klasse StringBuilder stehen u. a. die folgenden Konstruktoren zur Verfügung:
• public StringBuilder()
Beispiel: StringBuilder sb = new StringBuilder();
• public StringBuilder(String str)
Beispiel: StringBuilder sb = new StringBuilder("abc");
Im folgenden Programm wird eine Zeichenfolge 100.000-mal verlängert, zunächst mit Hilfe der
Klasse String, dann mit Hilfe der Klasse StringBuilder und schließlich mit der Klasse StringBuf-
fer:1
class SBBBench {
final static int DRL = 100_000;
static long vorher, diff;

public static void main(String[] args) throws Exception {


String s = "*";
vorher = System.currentTimeMillis();
for (int i = 0; i < DRL; i++)
s = s + "*";
diff = System.currentTimeMillis() - vorher;
System.out.println("Zeit in Millisekunden für die ...");
System.out.printf(" %-30s %7d\n", "String-\"Verlängerung\":", diff);

StringBuilder sbuild = new StringBuilder("*");


runAppend(sbuild);

StringBuffer sbuff = new StringBuffer("*");


runAppend(sbuff);
}

1
Ein IntelliJ-Projekt mit dem Programm ist im Ordner ...\BspUeb\Wichtige spezielle
Klassen\Zeichenfolgen\SBBBench zu finden.
304 Kapitel 5 Wichtige spezielle Klassen

static void runAppend (Appendable cs) throws Exception {


vorher = System.currentTimeMillis();
for (int i = 0; i < DRL; i++)
cs.append("*");
String s = cs.toString();
diff = System.currentTimeMillis() - vorher;
System.out.printf(" %-30s %7d\n", cs.getClass().getSimpleName() +
" - Verlängerung: ", diff);
}
}
Während bei Verwendung der Klasse String sehr viel Zeit verschwendet wird, unterscheiden sich
die Laufzeiten1 für die beiden anderen Klassen nur wenig, wobei aber der Preis für die Thread-
Sicherheit der Klasse StringBuffer zu erkennen ist:
Zeit in Millisekunden für die ...
String-"Verlängerung": 8627
StringBuilder - Verlängerung: 13
StringBuffer - Verlängerung: 16
Um Redundanz zu vermeiden, wird im Programm die statische Methode runAppend() mit einem
Parameter vom Typ der Schnittstelle Appendable verwendet. Mit Schnittstellen werden wir uns im
Kapitel 9 beschäftigen. Im Beispiel dient die Schnittstelle Appendable (aus dem Paket java.lang)
als gemeinsamer Datentyp für die Klassen StringBuilder und StringBuffer. Die in runAppend()
verwendete Methode append() kommuniziert per Ausnahmeobjekt. Weil wir uns um Ausnahmen
noch nicht kümmern wollen, melden wir in den Definitionsköpfen der Methoden runAppend()
und main() per throws-Klausel an, dass von ihnen Ausnahmen zu erwarten sind.
Ein StringBuilder-Objekt beherrscht u. a. die folgenden public-Methoden:
Methode Erläuterung
int length() Diese Methode liefert die aktuelle Anzahl der Zeichen.
append() Der StringBuilder wird um die Zeichenfolgen-Repräsentation des Argu-
ments verlängert, z. B.:
sb.append("*");
Es sind append() - Überladungen für zahlreiche Datentypen vorhanden.
insert() Die Zeichenfolgen-Repräsentation des Arguments, das von nahezu beliebi-
gem Typ sein kann, wird an einer bestimmten Stelle eingefügt, z. B.:
sb.insert(4, 3.14);
delete() Die Zeichen von einer Startposition (einschließlich) bis zu einer Endposition
(ausschließlich) werden gelöscht, z. B.:
sb.delete(1, 3);
replace() Ein Bereich des StringBuilder-Objekts wird durch den Parameter-String
ersetzt, z. B.:
sb.replace(1, 3, "xy");
String toString() Es wird ein String-Objekt mit dem Inhalt des StringBuilder-Objekts er-
zeugt. Dies ist z. B. erforderlich, um zwei StringBuilder-Objekte mit Hilfe
der String-Methode equals() vergleichen zu können:
sb1.toString().equals(sb2.toString())

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

5.2.3 Mehrzeilige Textblöcke


Die seit Java 13 im Vorschaustatus unterstützten und seit Java 15 im Standardsprachumfang enthal-
tenen mehrzeiligen Textblöcke erleichtern es, Segmente aus anderen Sprachen (z. B. HTML, SQL,
XML) in den Java-Quellcode aufzunehmen. Im folgenden Programm sind die traditionelle Syntax
und die Textblock-Lösung am Beispiel einer integrierten HTML-Seite zu sehen:1
class Prog {
public static void main(String[] args) {
String htmlKl =
"<html>\n" +
" <body>\n" +
" <p>Traditionelle Lösung</p>\n" +
" </body>\n" +
"</html>\n";
String htmlTB = """
<html>
<body>
<p>Textblock-Lösung</p>
</body>
</html>
""";
}
}

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.:

Um die Blockterminierung zur Definition von essentiellen Leerzeichen zu verwenden, ohne


dabei eine Leerzeile am Blockende zu bewirken, kann man den impliziten Zeilenwechsel am
Blockende mit der folgenden Escape-Sequenz verhindern:
\<Zeilentrennung>
Dazu lässt man die letzte Zeile des Blocks mit einem Rückwärtsschrägstrich enden, z. B.:
String htmlTB = """
<html>
<body>
<p>Textblock-Lösung</p>
</body>
</html>\
""";
• Leerzeichen am Ende der Textzeilen im Block werden abgeschnitten. Laskey & Marks
(2020) beschreiben Verfahren, um die Zeilen eines Textblocks mit Leerzeichen enden zu las-
sen.
• Die Zeilenterminatoren \r und \r\n werden vom Compiler durch \n ersetzt.
Aus den genannten Regeln resultiert die Identität der String-Objekte im folgenden Programm:
Quellcode Ausgabe
class Prog { Fred Feuerstein
public static void main(String[] args) {
String str = "Fred Feuerstein\n"; Fred Feuerstein
String strTB =
""" true
Fred Feuerstein
""";
System.out.println(str);
System.out.println(strTB);
System.out.println(str == strTB);
}
}

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

5.3 Verpackungsklassen für primitive Datentypen


In Java existiert zu jedem primitiven Datentyp eine Wrapper-Klasse, in deren Objekte jeweils ein
Wert des primitiven Typs verpackt werden kann (to wrap heißt einpacken):
Primitiver Datentyp Wrapper-Klasse
byte Byte
short Short
int Integer
long Long
double Double
float Float
boolean Boolean
char Character
Diese Verpackung ist z. B. dann erforderlich, wenn für einen Methodenparameter ein Referenzda-
tentyp verlangt wird.
Neben Ihrer Funktion, im Java-Typsystem die Kluft zwischen den Wert- und den Referenztypen zu
überbrücken, stellen die Wrapper-Klassen nützliche Konvertierungsmethoden und Konstanten be-
reit (als statische Methoden bzw. Felder).

5.3.1 Wrapper-Objekte erstellen


In der Regel verfügen die Wrapper-Klassen über zwei Konstruktoren mit jeweils einem Parameter,
der vom zugehörigen primitiven Typ bzw. vom Typ String ist, z. B. bei der Klasse Integer, deren
Objekte einen int-Wert verpacken:
• public Integer(int value)
Beispiel: Integer iu = new Integer(4711);
• public Integer(String str)
Beispiel: Integer iu = new Integer(args[0]);
Seit Java 9 sind die Wrapper-Konstruktoren abgewertet (engl.: deprecated), worauf unsere Ent-
wicklungsumgebung IntelliJ IDEA deutlich hinweist:

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');

System.out.println("Der ArrayList-Container enthält:");


for(Object o : al)
System.out.printf(" %-7s Typ: %-20s\n", o, o.getClass().getSimpleName());
}
}
Wie die folgende Programmausgabe zeigt, sind tatsächlich diverse Wrapper-Klassen im Spiel:
Der ArrayList-Container enthält:
Otto Typ: String
4711 Typ: Integer
23.77 Typ: Double
x Typ: Character

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

In den folgenden Zeilen findet ein Auto(un)boxing statt:


Integer iw = 4711;
int i = iw;
Aus dem Integer-Objekt iw wird der eingepackte Wert entnommen und der int-Variablen i zuge-
wiesen. Zeigt ein Integer-Objekt auf null, kommt es beim Autounboxing zur NullPointerExcepti-
on, wie das folgende Beispiel nach Bloch (2018, S. 274) demonstriert:
Quellcode Ausgabe
class Prog { Exception in thread "main"
static Integer iul; java.lang.NullPointerException
public static void main(String[] args) { at Prog.main(Prog.java:4)
System.out.println(iul == 13);
}
}

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.

5.3.3 Empfehlungen zur Verwendung von Verpackungsklassen


Verpackungsklassen sind z. B. erforderlich ...
• bei Methodenparametern mit Referenztyp
• zur Konkretisierung von Typformalparametern in generischen Klassen (siehe Abschnitt 8.1).
Jedoch muss bei der Verwendung von Wrapper-Objekten stets der gravierende Unterschied im Ver-
gleich zu Variablen mit einem primitiven Typ beachtet werden. Dieser Unterschied wird durch
(Un)boxing etwas versteckt, aber nicht beseitigt. Wird der Unterschied missachtet, drohen ...
• Programmierfehler (siehe Abschnitt 5.3.3.1).
• Schlechtes Leistungsverhalten von Programmen
Die unveränderlichen Wrapper-Objekte eignen sich nicht für häufig zu ändernde Werte (sie-
he Abschnitt 5.3.3.2).

5.3.3.1 Identitätsoperator vermeiden


Die Anwendung des Identitätsoperators auf Wrapper-Objekte kann zu gravierenden Fehlern führen,
wie das folgende Beispiel mit zwei Integer-Objekten demonstriert:
310 Kapitel 5 Wichtige spezielle Klassen

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

5.3.3.2 Wrapper-Objekte nicht für variable Werte verwenden


Weil Wrapper-Objekte unveränderlich sind, führt bei einer Wrapper-Referenzvariablen jede Wert-
zuweisung zu einer Objektkreation und damit zu einem erheblichen Zeitaufwand. Beim Vergleich
des eingepackten Werts mit einer int-Zahl ist ein Unboxing erforderlich. Daher sollte auf keinen
Fall ein Wrapper-Objekt einen Job übernehmen, den auch eine Variable mit dem zugehörigen pri-
mitiven Typ erfüllen kann.
Das folgende Programm benutzt ein Integer-Objekt in einer for-Schleife als Laufvariable, was
zahlreiche (Un)boxing-Operationen und einen hohen Zeitaufwand (in Millisekunden) verursacht:
Quellcode Ausgabe
class Prog { Zeitaufwand: 167
public static void main(String[] args) { Zeitaufwand: 17
double summe = 0.0;
long start = System.currentTimeMillis();
for (Integer i = 0; i < 10_000_000; i++)
summe += i;
System.out.println("Zeitaufwand:\t" +
(System.currentTimeMillis()-start));

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);

5.3.5 Konstanten für Grenz- bzw. Spezialwerte


In den numerischen Verpackungsklassen sind öffentliche und finalisierte Klassenvariablen für di-
verse Grenz- bzw. Spezialwerte definiert, z. B. in der Klasse Double:
Abschnitt 5.4 Aufzählungstypen 313

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.3.6 Character-Methoden zur Zeichen-Klassifikation


Die Verpackungsklasse Character zum primitiven Typ char bietet einige öffentliche und statische
Methoden zur Klassifikation von Unicode-Zeichen, die bei der Verarbeitung von Textdaten nützlich
sein können:
Methode Erläuterung
boolean isDigit(char ch) Die Methode liefert den Wert true zurück, wenn das über-
gebene Zeichen eine Ziffer ist, sonst false.
boolean isLetter(char ch) Die Methode liefert den Wert true zurück, wenn das über-
gebene Zeichen ein Buchstabe ist, sonst false.
boolean isLetterOrDigit(char ch) Die Methode liefert den Wert true zurück, wenn das über-
gebene Zeichen ein Buchstabe oder eine Ziffer ist, sonst fal-
se.
boolean isWhitespace(char ch) Die Methode liefert den Wert true zurück, wenn ein Trenn-
zeichen übergeben wurde, sonst false. Zu den Trennzeichen
gehören in Java (siehe Gosling et al. 2021, Abschnitt 3.6):
• Leerzeichen (\u0020)
• Tabulatorzeichen (\u0009)
• Wagenrücklauf (\u000D)
• Zeilenvorschub (\u000A)
boolean isLowerCase(char ch) Die Methode liefert den Wert true zurück, wenn das über-
gebene Zeichen ein Kleinbuchstabe ist, sonst false.
boolean isUpperCase(char ch) Die Methode liefert den Wert true zurück, wenn das über-
gebene Zeichen ein Großbuchstabe ist, sonst false.

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

• Eine String-Variable zur Aufnahme der Temperamentsbezeichnung


Dabei drohen Fehler durch inkonsistente Schreibweisen, z. B.:
if (otto.temp == "Fleckmatisch") ...
• Eine int-Variable mit der Codierungsvorschrift 0 = melancholisch, 1 = cholerisch, etc.
Dabei ist der Quellcode nur für Eingeweihte zu verstehen, z. B.:
if (otto.temp == 3) ...
Durch Datenkapselung mit entsprechenden Zugriffsmethoden sowie sorgfältige Arbeitsweise des
Klassendesigners könnte man immerhin für eine Instanzvariable vom Typ String oder int sicher-
stellen, dass ausschließlich die vier vorgesehenen Temperamentswerte auftreten. Im Quellcode vor-
handene Versuche, einen irregulären Wert zu vergeben, würden allerdings erst zur Laufzeit ent-
deckt.
Java enthält mit den Enumerationen (Aufzählungstypen) eine Lösung, die folgende Vorteile bie-
tet:
• Eine exakt definierte Menge gültiger Werte
Damit ist die Einhaltung des Wertebereichs nicht mehr von der Sorgfalt des Klassendesig-
ners abhängig, sondern wird vom Compiler sichergestellt. Außerdem werden im Quellcode
vorhandene Versuche, einen irregulären Wert zu vergeben, schon zur Übersetzungszeit ent-
deckt und nicht erst zur Laufzeit.
• Gut lesbarer Quellcode
Im obigen Beispiel kann z. B. der folgende logische Ausdruck verwendet werden:
if (otto.temp == Temperament.PHLEGMATISCH) ...

5.4.1 Einfache Enumerationstypen


Bei der Definition eines einfachen Aufzählungstyps folgt nach dem optionalen Zugriffsmodifikator
auf das Schlüsselwort enum und den Typbezeichner eine geschweift eingeklammerte Liste mit den
Namen für eine feste Anzahl von Enumerationskonstanten:
Einfache Enumerationsdefinition

enum Name { Enumerationskonstante }

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

public class Person {


public String vorname, name;
public int alter;
public Temperament temp;
public Person(String vor, String nach, int alt, Temperament tp) {
vorname = vor;
name = nach;
alter = alt;
temp = tp;
}
public Person() {}
}

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
}
}

Im Project-Fenster unserer Entwicklungsumgebung unterscheiden sich Enumerationen von norma-


len Klassen durch ein Symbol mit einem E (statt einem C) im blauen Kreis, z. B.:

5.4.2 Erweiterte Enumerationstypen


Eine Enumerationsklasse kann mit Instanzvariablen, Methoden und privaten Konstruktoren ausge-
stattet werden. Objekte der folgenden Enumeration TemperamentEx geben über die Methoden
stable() bzw. extra() Auskunft darüber, ob die zugehörige Persönlichkeit emotional stabil
bzw. extravertiert ist:1

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

public enum TemperamentEx {


MELANCHOLISCH(false, false),
CHOLERISCH(false, true),
PHLEGMATISCH(true, false),
SANGUIN(true, true);

private final boolean stable, extra;

TemperamentEx(boolean stab, boolean ex) {


stable = stab;
extra = ex;
}

public boolean stable() {return stable;}


public boolean extra() {return extra;}
}
Diese Informationen befinden sich in Instanzvariablen, die von einem Konstruktor initialisiert wer-
den. Zur Verwendung des Konstruktors hängt man Aktualparameterlisten an die Enumerations-
konstanten an. Der Konstruktor ist implizit privat, wobei der Zugriffsmodifikator private erlaubt,
aber nicht erforderlich ist. Andere Zugriffsmodifikatoren sind verboten.
Im Beispiel sind die Instanzvariablen des erweiterten Enumerationstyps als final deklariert, sodass
unveränderliche Enumerationsobjekte resultieren. Die Objekte eines erweiterten Enumerationstyps
können aber auch als veränderlich konzipiert werden, z. B.:
private boolean stable, extra;
. . .
public void changeExtra(boolean ex) {extra = ex;}

Über die sogenannte konstanten-spezifische Methodenimplementation erstellt man zu einer Enume-


ration eine Methode, die von den Objekten des Typs unterschiedlich ausgeführt wird. Ein Beispiel
ist die von Bloch (2018, S. 162f) beschriebene Enumeration Operation mit Objekten für die vier
Grundrechnungsarten und der jeweils speziell implementierten Methode apply():
public double apply(double x, double y)

5.4.3 Innere und lokale Aufzählungstypen


Ein Aufzählungstyp kann nicht nur auf Paketebene definiert werden, sondern ...
• auch innerhalb einer Klasse (vgl. Abschnitt 4.8.1)
• und seit Java 16 auch innerhalb einer Methode (vgl. Abschnitt 4.8.2).
Beim Einstieg in Java sollte man sich aber mit diesen eher selten benötigten Optionen noch nicht
beschäftigen.
Ein als Klassenmitglied definierter Aufzählungstyp ist implizit statisch (vgl. Abschnitt 4.8.1.2),
d. h.
• Objekte dieses Typs besitzen keine implizite Referenz auf ein umgebendes Objekt der äuße-
ren Klasse. Das wäre bei den Enumerations-Objekten sinnlos.
• Der Aufzählungstyp verhält sich wie ein Top-Level - Typ, hat aber Zugriff auf die statischen
Mitglieder der äußeren Klasse (auch auf die privaten) und muss einen Doppelnamen führen.
• Der Modifikator static ist überflüssig, aber erlaubt.
In der folgenden Variante des Beispiels aus dem Abschnitt 5.4.1 wird Temperament als implizit
statische Mitglieds-Enumeration definiert:
318 Kapitel 5 Wichtige spezielle Klassen

public class Person {


public enum Temperament {MELANCHOLISCH, CHOLERISCH, PHLEGMATISCH, SANGUIN}

public String vorname, name;


public int alter;
public Temperament temp;
public Person(String vor, String nach, int alt, Temperament tp) {
vorname = vor;
name = nach;
alter = alt;
temp = tp;
}
public Person() {}
}

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");

for (Person.Temperament t : Person.Temperament.values())


System.out.println(t.name());
}
}

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);

private final boolean stable, extra;


private TemperamentEx(boolean stab, boolean ex) {
stable = stab;
extra = ex;
}

public boolean stable() {return stable;}


public boolean extra() {return extra;}
public int reportStaticEnvironment() {return stati;} // erlaubt
// public int reportLocalEnvironment() {return loci;} //verboten
}
Abschnitt 5.5 Records 319

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() {}
}

Person kandidat = new Person("Karl", "Rempremmerding", 35, TemperamentEx.SANGUIN);


System.out.println("Name: " + kandidat.vorname + " " + kandidat.name +
"\nTemperament: " + kandidat.temp +
"\nEmotional stabil: " + kandidat.temp.stable() +
"\nExtravertiert: " + kandidat.temp.extra());
}
}

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.

5.5.1 Einfach Datenklassendefinition


Selbstverständlich ist eine pure Datenklasse mit den eingangs beschriebenen Eigenschaften in Java
problemlos zu realisieren, z. B. bei einer Klasse für Punkte mit ganzzahligen X- und Y-
Koordinaten:
import java.util.Objects;

public final class Point {


// finalisierte, private Felder zu den Komponenten
private final int x;
private final int y;
320 Kapitel 5 Wichtige spezielle Klassen

// Kanonischer Konstruktor
public Point(int x, int y) {
this.x = x;
this.y = y;
}

// Getter
int x() { return x; }
int y() { return y; }

// equals() - Überschreibung mit inhaltsorientierter Beurteilung


public boolean equals(Object obj) {
if (obj == null || !(obj instanceof Point))
return false;
Point p = (Point) obj;
return p.x == this.x && p.y == this.y;
}

// Mit equals() kompatible hashCode() - Überschreibung


public int hashCode() {
return Objects.hash(x, y);
}

// toString() - Überschreibung
public String toString() {
return "Point[x=" + this.x + ", y=" + this.y + "]";
}
}

Die Klasse Point besitzt ...


• die finalisierten int Instanzvariablen x und y
• einen Konstruktor, der bei neuen Objekten die beiden Instanzvariablen initialisiert
• lesende Zugriffsmethoden x() und y() für die beiden Instanzvariablen
• eine inhaltsorientierte Überschreibung der von Object geerbten Methode equals()
Zwei Point-Objekte werden von der überschreibenden equals() - Methode als gleich beur-
teilt, wenn sie bei beiden Instanzvariablen denselben Wert besitzen. Die Object-Methode
equals() beurteilt zwei Referenzvariablen als identisch, wenn sie auf dasselbe Objekt zei-
gen.
• eine zur equals() - Überschreibung kompatible Überschreibung der von Object geerbten
Methoden hashCode()
Wenn zwei Point-Objekte von der equals() - Methode als gleich beurteilt werden, dann
muss die hashCode() - Methode für die beiden Objekte denselben Wert liefern. Mit Hilfe
der statischen Methode hash() aus der Klasse Objects (im Paket java.util) wird dieses Ver-
halten erzielt. Die Methode hashCode() spielt u. a. bei der Klasse HashSet<E> im Java
Collections Framework eine wichtige Rolle (siehe Abschnitt 10.6.3).
• eine Überschreibung der von Object geerbten Methoden toString()
Dass sich eine informative Selbstdarstellung von Point-Objekten über die (z. B. von
System.out.println() automatisch aufgerufene) Methode toString() lohnt, ist unmittelbar
klar.
In Anbetracht der simplen Aufgabenstellung sind etliche Code-Zeilen erforderlich, die zudem mehr
oder weniger mechanisch, eventuell sogar mit Hilfe einer Entwicklungsumgebung erstellt werden.
Dieser sogenannte Boilerplate-Code ist sehr unbeliebt, weil Entwicklungszeit verschwendet wird.
Davon ist nicht nur der Autor betroffen, sondern auch andere Nutzer des Quellcodes, denn:
Abschnitt 5.5 Records 321

• Unter dem wachsenden Umfang leidet die Übersichtlichkeit des Codes.


• Nach einer sorgfältigen Inspektion des Boilerplate-Codes stellt man in der Regel fest, dass
der Aufwand überflüssig war.
Nicht vorhandener Code muss auch nicht gelesen und auf Relevanz überprüft werden. Routine-
Code kann auch dann Kosten verursachen, wenn er von Werkzeugen erstellt wird, weil er eventuell
von mehreren Personen gelesen und kontrolliert werden muss.
Zur obigen Point-Klassendefinition ist die folgende Record-Klassendefinition äquivalent:
public record Point(int x, int y) { }
Auf das Schlüsselwort record folgt der Name der Record-Klasse und eine als Deskriptor bezeich-
nete Liste mit den sogenannten Komponenten der Klasse, wobei die Syntax einer Konstruktor-
Parameterliste Verwendung findet.
Wie der obige Quellcode demonstriert, wird zu jeder Record-Komponente eine gleichnamige Zu-
griffsmethode erstellt. Daher scheiden einige Komponentenbezeichnungen aus, weil Überschrei-
bungen von Object-Methoden resultieren würden. Von dieser leicht zu verschmerzenden Ein-
schränkungen sind die folgenden Namen betroffen: clone, equals, finalize, getClass,
hashCode, notify, notifyAll, toString, wait.
Eine Zugriffsmethode übernimmt von ihrer Komponente nicht nur den Namen, sondern auch den
Datentyp für die Rückgabe. Damit wird die ansonsten bei Java-Klassen vorhandene Entkopplung
von API und interner Repräsentation aufgegeben. Warum trotzdem bei Java-Records keine öffentli-
chen Felder an Stelle der Zugriffsmethoden verwendet werden, ist im Abschnitt 5.5.2.3 zu erfahren.
Das folgende Programm
Quellcode Ausgabe
class Prog { Point[x=5, y=10]
public static void main(String[] args) { true
var p = new Point(5, 10); true
var p2 = new Point(5, 10);
System.out.println(p);
System.out.println(p.equals(p2));
System.out.println(p.hashCode() == p2.hashCode());
}
}

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

Selbstverständlich bleiben die bislang im Manuskript dominierenden Klassen mit veränderlichen


Objekten in vielen Fällen unverzichtbar.

5.5.2 Optionen und Einschränkungen bei der Definition von Record-Klassen


Java bietet einige Möglichkeiten, um die automatisch erstellten Record-Klassenmitglieder zu än-
dern oder zusätzliche Mitglieder zu definieren, wobei man natürlich möglichst wenig von der ange-
nehm kompakten Standarddefinition abweichen möchte, z. B.:
public record Point(int x, int y) { }

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.

5.5.2.2 Expliziter Konstruktor


Ein explizit erstellter Konstruktor kommt vor allem dann in Frage, wenn die übergebenen Parameter
validiert werden sollen. Wenn die Record-Klasse Point Bildschirm-Koordinaten verwalten soll,
dann sind negative Parameterwerte als fehlerhaft zurückzuweisen, was im folgenden Konstruktor
geschieht:
public Point(int x, int y) {
if(x < 0 || y < 0) {
throw new IllegalArgumentException("Die Koordinaten müssen positiv sein.");
}
this.x = x;
this.y = y;
}
Wird der Konstruktor mit ungeeigneten Argumenten aufgerufen, dann wirft er eine
IllegalArgumentException. Mit dieser Technik zur Fehlerbehandlung werden wir uns im Kapitel
11 ausführlich beschäftigen.
Im expliziten, validierenden Konstruktor tauchen die Namen der Record-Komponenten viermal auf.
Um den Kontrast zur angenehm kompakten Standarddefinition der Record-Klasse etwas abzumil-
dern, ist für den Point-Konstruktor die folgende Kurzform erlaubt:
public Point {
if(x < 0 || y < 0) {
throw new IllegalArgumentException("Die Koordinaten müssen positiv sein.");
}
}
Zwei aus dem mechanischen Befolgen von strikten Regeln resultierende Bestandteile dürfen weg-
gelassen werden:
• die Parameter des Konstruktors
• die Übertragung der Parameterwerte auf die Instanzvariablen
Ein expliziter Konstruktor darf den Zugriffsschutz des kanonischen Konstruktors (= Zugriffsschutz
der Record-Klasse) nicht verschärfen.
Abschnitt 5.5 Records 323

5.5.2.3 Überschreiben der automatisch erstellten Methoden


Die automatisch erstellten Methoden (equals(), hashCode(), toString() und die Zugriffsmethoden)
dürfen überschrieben werden.
Während die Überschreibung der Methode toString() weitgehend ins eigene Ermessen gestellt ist,
muss bei den Überschreibungen der anderen Methoden die folgende Regel (man sagt auch: die fol-
gende Invariante) aus der API-Dokumentation zur Klasse Record im Paket java.lang, der implizi-
ten Basisklasse aller Record-Klassen, beachtet werden:1
Ist R eine Record-Klasse mit den Komponenten c1, c2, ..., cn und r ein Objekt dieser Klasse,
dann muss für eine Kopie
R copy = new R(r.c1(), r.c2(), ..., r.cn());
der Aufruf
r.equals(copy)
die Rückgabe true liefern.
Außerdem müssen die Methoden equals() und hashCode() generell kompatibel sein: Wenn zwei
Objekte einer Record-Klasse von der equals() - Methode als gleich beurteilt werden, dann muss die
hashCode() - Methode für die beiden Objekte denselben Wert liefern.
Gründe für das Überschreiben von Zugriffsmethoden sind z. B.:
• Nebeneffekte
Eventuell soll eine Zugriffsmethode nicht nur den angeforderten Wert abliefern, sondern
auch noch eine andere Aktion ausführen (z.B. einen Protokolleintrag schreiben). Obwohl
Nebeneffekte von Methoden ein Programm unübersichtlich machen können, sollen sie nicht
generell verteufelt werden.
• Defensive Kopien
Die Komponenten (Instanzvariablen) eines Record-Objekts können nach der Initialisierung
nicht mehr geändert werden. In der API-Dokumentation zur Klasse Record wird ein Re-
cord-Objekt als oberflächlich unveränderlich (engl.: shallowly immutable) bezeichnet weil
eine Instanzvariable einen Referenztyp haben darf, und die zugehörige Klasse keinesfalls
unveränderlich sein muss. Damit eine per Zugriffsmethode ausgelieferte Objektreferenz
nicht dazu missbraucht werden kann, das Member-Objekt zu verändern, kommt die Auslie-
ferung einer Kopie des referenzierten Objekts in Frage, sodass Änderungen durch den Emp-
fänger der Referenz das Original nicht beeinflussen. Eine solche Kopie bezeichnet man als
defensiv.
Kommt die Technik der defensiven Kopie in einer Zugriffsmethoden-Überschreibung zum Einsatz,
dann müssen auch die Methoden equals() und hashCode() überschrieben werden, damit die oben
beschriebene Invariante der Klasse Record eingehalten wird und die beiden Methoden kompatibel
bleiben.
Kämen beim lesenden Zugriff auf die Record-Komponenten öffentliche Felder statt Zugriffsmetho-
den zum Einsatz, dann wären defensive Kopien sowie Zugriffs-Nebeneffekte nicht realisierbar.

5.5.2.4 Zusätzliche Record-Member


In einer Record-Klasse können über die Komponenten aus dem Deskriptor und den automatisch
erstellten Methoden hinaus einige weitere Mitglieder definiert werden:

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

5.5.2.5 Innere und lokale Record-Klassen


Records können nicht nur als Top-Level - Klassen definiert werden, sondern auch als Mitglieds-
klassen und als lokale Klassen (vgl. Abschnitt 4.8). Wenn eine Record-Klasse lediglich in einer
Klasse oder in einer Methode benötigt wird, dann verbessert eine lokale Definition die Lesbarkeit
des Quellcodes:
• Die Record-Definition befindet sich dort, wo sie am ehesten interessiert.
• Der Typ von lediglich lokaler Bedeutung erhöht nicht die Komplexität auf einer höheren
Ebene der Quellcode-Organisation.
Eine Record-Mitgliedsklasse ist im Unterschied zu einer normalen Mitgliedsklasse implizit statisch,
sodass ein Objekt einer Record-Mitgliedsklasse ohne einhüllendes Objekts der umgebenden Klasse
erstellt werden kann (vgl. Abschnitt 4.8.1.2). Ein solches einhüllendes Objekt würde nämlich
klammheimlich den Zustand des Record-Objekts über die Record-Komponenten hinaus erweitern.
Während bei einer normalen Mitgliedsklasse der Modifikator static darüber entscheidet, ob es sich
um eine innere Klasse oder um eine statische Mitgliedsklasse handelt, ist eine Record-
Mitgliedsklasse implizit statisch. Der Modifikator static ist hier überflüssig und verboten, z. B.:
public class Aussen {
record StatischerMitgliedsRecord(int x, int y) { }
static class StatischeMitgliedklasse { }
}

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);

5.6 Übungsaufgaben zum Kapitel 5

Abschnitt 5.1 (Arrays)


1) Welche der folgenden Aussagen sind richtig bzw. falsch?
1. Die Länge eines Arrays muss zur Übersetzungszeit festgesetzt werden.
2. Die Länge eines Arrays muss beim Erzeugen (zur Laufzeit) festgesetzt werden.
3. Array-Elemente werden automatisch mit der typspezifischen Null initialisiert, weil es sich
um Instanzvariablen handelt.
4. Von der erweiterten for-Schleife (siehe Abschnitt 3.7.3.2) werden auch Arrays unterstützt.
5. Die Länge eines Arrays lässt sich mit der Instanzmethode length() ermitteln.

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 2b, 3b, ...) 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

• eine Methode zum Transponieren der Matrix


• Methoden für elementare statistische Analysen mit den Spalten der Matrix:
o Eine Methode sollte den eindimensionalen Array mit den Mittelwerten der Spalten
als Rückgabe liefern. Der Mittelwert aus den Beobachtungswerten x1, x2, …, xn ist
definiert durch
1 n
x :=  xi
n i=1
o Eine Methode sollte den eindimensionalen Array mit den Varianzen der Spalten als
Rückgabe liefern. Der erwartungstreue Schätzer für die Varianz der zu einer Spalte
gehörigen Zufallsvariablen mit den Beobachtungswerten x1, x2, …xn ist definiert
durch
1 n
ˆ 2 := 
n − 1 i=1
( xi − x ) 2

Zur Vereinfachung der Berechnung kann die folgende Verschiebungsformel dienen:


n n

 ( 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.

Abschnitt 5.2 (Klassen für Zeichen)


1) Welche von den folgenden Aussagen sind richtig bzw. falsch?
1. Mit Hilfe der for-Schleife für Arrays und Kollektionen (vgl. Abschnitt 3.7.3.2) kann man
bequem über die Zeichen eines String-Objekts iterieren.
2. Die Anzahl der Zeichen in einem String-Objekt lässt sich mit der Instanzmethode length()
ermitteln.
3. Auf die Zeichen eines String-Objekts kann man wie bei einem Array per Indexoperator zu-
greifen.
4. Ein String-Objekt kann nach dem Erstellen nicht mehr geändert werden.

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

Dieser Satz passt nicht in eine Schmal-


Zeile, die nur wenige Spalten umfasst.
Ein wesentlicher Schritt zur Lösung des Problems ist die Zerlegung der Zeichenfolge in Einzelbe-
standteile (sogenannte Tokens), die nach Möglichkeit nicht durch einen Zeilenumbruch aufgetrennt
werden sollten. Diese Zerlegung können Sie einem Objekt der Klasse StringTokenizer aus dem
Paket java.util überlassen. Im folgenden Programm wird demonstriert, wie ein StringTokenizer
arbeitet:
Quellcode Ausgabe
class Prog { Dies
public static void main(String[] args) { ist
String s = "Dies ist der Satz, der zerlegt werden soll."; der
java.util.StringTokenizer stok = Satz,
der
new java.util.StringTokenizer(s, " ", false); zerlegt
while (stok.hasMoreTokens()) werden
System.out.println(stok.nextToken()); soll.
}
}

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.

Abschnitt 5.3 (Verpackungsklassen für primitive Datentypen)


1) Ermitteln Sie den kleinsten möglichen Wert des Datentyps byte.

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

Abschnitt 5.4 (Aufzählungstypen)


1) Erstellen und erproben Sie einen Datentyp namens Wochentag, der folgende Bedingungen er-
füllt:
• Typsicherheit
Einer Variablen vom Typ Wochentag können nur sieben verschiedene Werte zugewiesen
werden, die den Wochentagen Sonntag, Montag, usw. entsprechen.
• Ordnungsinformation
Für zwei Werte des Typs Wochentag kann leicht die zeitliche Anordnung festgestellt wer-
den.
• Leicht lesbarer Quellcode
• Verwendbarkeit als Datentyp für den steuernden Ausdruck einer traditionellen switch-
Anweisung

Abschnitt 5.5 (Records)


1) Welche der folgenden Aussagen über Record-Klassen sind richtig bzw. falsch?
1. Über die Komponenten im Deskriptor hinaus darf man keine weiteren Instanzvariablen de-
klarieren.
2. Man darf statische Felder deklarieren und statische Methoden definieren.
3. Die automatisch erstellten Methoden (equals(), hashCode(), toString() und die Zugriffsme-
thoden) dürfen nicht überschrieben werden
4. Die Gleichheitsbeurteilung für Record-Objekte erfolgt inhaltsorientiert.

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

6.1.1 Pakete erstellen

6.1.1.1 package-Deklaration und Paketordner


Wir betrachten ein einfaches Paket namens demopack mit den Klassen A, B und C. An den Anfang
jeder einzubeziehenden Quellcodedatei ist eine package-Deklaration mit dem Paketnamen zu set-
zen, der üblicherweise komplett klein geschrieben wird, z. B.:2

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;
}

public void prinr() {


System.out.println("Klasse A, Objekt Nr. " + objnr);
}
}
Vor der package-Deklaration dürfen höchstens Kommentar- oder Leerzeilen stehen.1
Sind in einer Quellcodedatei mehrere Typdefinitionen vorhanden, was in Java nur unter bestimmten
Bedingungen erlaubt und generell nicht zu empfohlen ist, dann werden alle Typen (Klassen und
Interfaces) dem Paket zugeordnet.
Die Typen eines benannten Pakets können von Typen in fremden Paketen nur dann verwendet wer-
den, wenn durch den Modifikator public die Genehmigung dazu erteilt wurde. Zusätzlich müssen
auch Methoden, Konstruktoren, Felder und andere Mitglieder eines Typs explizit per Zugriffsmodi-
fikator für fremde Pakete freigegeben werden. Steht z. B. in einer als öffentlich deklarierten Klasse
kein public-Konstruktor zur Verfügung, dann können fremde Pakete die Klasse zwar „sehen“ und
ggf. auf öffentliche statische Mitglieder zugreifen, aber keine Objekte dieses Typs erzeugen. Mit
den Zugriffsrechten für Typen und Typmitglieder werden wir uns im Abschnitt 6.3 detailliert be-
schäftigen.
Bei der Paketablage in einem Dateisystem gehören die class-Dateien mit den Klassen und Schnitt-
stellen eines Pakets in einen gemeinsamen Ordner, dessen Name mit dem Paketnamen identisch
ist.2 In unserem Beispiel mit den Klassen A, B und C im Paket demopack muss also die folgende
Situation hergestellt werden:
• Die drei Bytecode-Dateien A.class, B.class und C.class befinden sich in einem Ordner na-
mens demopack:

 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;
}

public void prinr() {


System.out.println("Klasse X, Objekt Nr. " + objnr);
}
}

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.

6.1.1.4 Paketunterstützung in IntelliJ


Nachdem schon einige Quellen aus dem Beispielpaket demopack zu sehen waren, erstellen wir es
nun mit IntelliJ. Wir starten ein neues Java-Projekt mit dem Namen PackDemo und verwenden da-
bei erstmals ein Base package:

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:

Nach dem OK startet IntelliJ im Editor eine Klassendefinition mit package-Deklaration

und zeigt im Project-Fenster den aktuellen Entwicklungsstand:

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;

public class B { public class C {


private static int anzahl = 0; private static int anzahl;
private int objnr; private int objnr;

public B() { public C() {


objnr = ++anzahl; objnr = ++anzahl;
} }

public void prinr() { public void prinr() {


System.out.println("Klasse B, Objekt Nr. " System.out.println("Klasse C, Objekt Nr. "
+ objnr); + objnr);
} }
} }

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.*;

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();
}
}
Das Programm liefert die folgende Ausgabe:
Klasse A, Objekt Nr. 1
Klasse A, Objekt Nr. 2
Klasse B, Objekt Nr. 1
Klasse C, Objekt Nr. 1
Klasse X, Objekt Nr. 1
Klasse Y, Objekt Nr. 1

6.1.1.5 Regeln und Konventionen für Paketnamen


Es ist üblich, für Paketnamen ausschließlich Kleinbuchstaben zu verwenden, sodass die Unterschei-
dung von Typnamen, die per Konvention mit einem Großbuchstaben beginnen, leichtfällt. Meist
beschränkt man sich auf Kleinbuchstaben aus dem ASCII-Zeichensatz, was laut Java-
Sprachspezifikation für das erste Segment eines hierarchisch strukturierten Paketnamens nach-
drücklich empfohlen wird.
Weil der Punkt die Segmente eines hierarchisch strukturierten Paketnamens trennt, darf er nicht
innerhalb eines Namenssegments verwendet werden. Der Klassenlader der JVM erwartet bei der
Suche nach Bytecode-Dateien die Ablage in einer zur Paketnamensstruktur passenden Ordnerstruk-
tur (im Dateisystem oder in Java-Archiven). Das folgende IntelliJ-Projekt arbeitet mit dem Paket
demo.pack und verstößt dabei gegen keine Regel:
340 Kapitel 6 Pakete und Module

Die Entwicklungsumgebung legt die Bytecode-Dateien korrekt im Ordner ...\demo\pack ab:

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:

• Bestandteile im Domänenamen, die zu einem ungültigen Java-Bezeichner führen würden,


müssen ersetzt werden, z. B. der Bindestrich durch einen Unterstrich (Gosling et al 2021,
Abschnitt 6.1). Im ZIMK an der Universität Trier kann z. B. für den klientenseitigen Teil ei-
ner Kommunikations-Software der folgende Paketpfad verwendet werden:
de.uni_trier.zimk.chat.client
• Eine Ausnahme von den Konventionen für die Bildung von weltweit eindeutigen Paketna-
men bestehen bei den Paketen der Standardbibliothek, die mit java oder javax beginnen.

6.1.2 Pakete verwenden


Damit man die in fremden Paketen vorhandenen Typen in einem eigenen Programm verwenden
kann, …
• müssen die class-Dateien für die beteiligten Werkzeuge (Compiler, JVM) auffindbar sein
• und die Klassen bzw. Schnittstellen im eigenen Quellcode korrekt angesprochen werden.
Mit den beiden Aufgaben wurden wir schon konfrontiert, doch sollen die zugehörigen Lösungen
gleich noch einmal zusammengestellt werden.
Mit einer weiteren Nutzungsvoraussetzung, den Zugriffsrechten, werden wir uns im Abschnitt 6.3
beschäftigen.

6.1.2.1 Verfügbarkeit der class-Dateien (Klassenpfad)


Damit die in einem Paket vorhandenen Typen beim Übersetzen bzw. bei der Ausführung eines Pro-
gramms genutzt werden können, muss dem Compiler bzw. der JVM der Aufenthaltsort der Typen
bekannt gemacht werden. Die Typen in den API-Paketen werden auf jeden Fall gefunden. Welche
Maßnahmen bei anderen Paketen erforderlich sind, wird anschließend beschrieben (vgl. auch Ab-
schnitt 2.2.4). Wir ignorieren vorläufig Pakete in Java-Archiven (siehe Abschnitt 6.1.3) und be-
schränken uns passend zum Entwicklungsstand des demopack-Beispiels auf Pakete in Verzeichnis-
sen. Wir behandeln das Übersetzen und die Ausführung eines paketierten Java-Programms auf ei-
nem Windows-Rechner mit installiertem OpenJDK, d .h.:
• Der Compiler javac.exe und der Starter java.exe sind vorhanden
• und befinden sich in einem Ordner, der von Windows dank PATH-Definition nach ausführ-
baren Programmen durchsucht wird.
Der Stammordner des Pakets (im Beispiel: der Ordner, der das Verzeichnis demopack enthält)
muss über die CLASSPATH-Umgebungsvariable oder per classpath-Befehlszeilenoption be-
kanntgegeben werden:
342 Kapitel 6 Pakete und Module

• Stammordner des Pakets in der CLASSPATH-Umgebungsvariablen eingetragen


Über die Betriebssystem-Umgebungsvariable CLASSPATH lässt sich der Suchpfad für
class-Dateien definieren. Wenn sich z. B. der Ordner zur oberste Paketebene (in unserem
Beispiel: demopack) im Ordner
U:\Eigene Dateien\Java\lib
befindet,

dann kann die CLASSPATH-Definition lauten (vgl. Abschnitt 2.2.4):

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

eines Konsolenfensters die Datei PackDemo.java mit dem folgenden Inhalt,


import demopack.*;
import demopack.sub1.*;
class PackDemo {
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();
}
}
dann gelingt aufgrund der obigen CLASSPATH-Definition die Übersetzung mit dem fol-
genden Kommando:
Abschnitt 6.1 Pakete 343

>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.

6.1.2.2 Typen aus fremden Paketen ansprechen


In einer Quellcodedatei können Typen aus dem eigenen Paket sowie Typen aus dem (automatisch
importierten) Paket java.lang ohne Paketpräfix angesprochen werden. Auf folgende Weise lassen
sich (entsprechende Rechte vorausgesetzt) Typen aus einem anderen fremden Paket ansprechen:
• Verwendung des vollqualifizierten Namens
Dem Klassennamen ist der durch Punkt abgetrennte Paketname voranzustellen. Bei einem
hierarchischen Paketaufbau ist der gesamte Pfad anzugeben, wobei die Unterpaketnamen
wiederum durch Punkte zu trennen sind. Wir haben bereits mehrfach die Klasse Random
aus dem Paket java.util auf diese Weise angesprochen, z. B.:
java.util.Random zzg = new java.util.Random();
Bei einem mehrfach benötigten Typ wird es schnell lästig, den vollqualifizierten Namen
schreiben zu müssen. Außerdem erschweren zahlreich auftretende Paketnamen die Lesbar-
keit des Quellcodes.
• Import eines einzelnen Typs
Um die lästige Angabe von Paketnamen zu vermeiden, kann man eine Klasse oder Schnitt-
stelle in eine Quellcodedatei importieren. Anschließend ist der Typ durch seinen einfachen
Namen (ohne Paket-Präfix) anzusprechen. Die zuständige import-Deklaration ist an den
Anfang einer Quellcodedatei zu setzen, ggf. aber hinter eine package-Deklaration (vgl. Ab-
schnitt 6.1.1.1). Im folgenden Programm wird die Klasse Random aus dem API-Paket ja-
va.util importiert und verwendet:
344 Kapitel 6 Pakete und Module

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

6.1.2.3 Startklasse in einem benannten Paket


Wir haben uns im bisherigen Kursverlauf nur wenig damit beschäftigt, wie ein Java-Programm
außerhalb unserer Entwicklungsumgebung IntelliJ gestartet werden kann (z. B. auf einem Kunden-
rechner). Bei den wenigen Anwendungsbeispielen lag eine recht simple Situation vor:
• Die zu startende Hauptklasse befand sich im Standardpaket.
• Die class-Datei der Startklasse befand sich im aktuellen Verzeichnis des Konsolenfensters,
in dem der Java-Starter aufgerufen wurde. In diesem Verzeichnis befanden sich auch die
class-Dateien von weiteren benötigten Klassen.
• Es war keine explizite CLASSPATH-Definition im Spiel, sodass sich das aktuelle Ver-
zeichnis der Konsole im impliziten Klassensuchpfad befand.
Diese Situation lag z. B. im Einleitungsbeispiel des Kurses vor (siehe Abschnitt 1.2.2):

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.

6.1.3 Traditionelle jar-Dateien (Java 8)


Wie wir inzwischen wissen, besteht ein Java-Programm bzw. eine Java-Bibliothek aus Paketen, die
jeweils Typen (Klassen und/oder Schnittstellen) in class-Dateien enthalten. Außerdem sind oft noch
Hilfsdateien (z. B. mit übersetzten Texten oder Multimedia-Inhalten) vorhanden. Zur Auslieferung
an die Benutzer bietet sich die Zusammenfassung zu einer Java-Archivdatei (Namenserweiterung
348 Kapitel 6 Pakete und Module

.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.

6.1.3.1 Eigenschaften von Java-Archivdateien


Java-Archivdateien bieten viele Vorteile, z. B.:
• Übersichtlichkeit, Bequemlichkeit
Im Vergleich zu zahlreichen Einzeldateien ist ein Archiv für den Anwender deutlich beque-
mer. Ein per Archiv ausgeliefertes Programm kann sogar direkt über die Archivdatei gestar-
tet werden, bei entsprechender Konfiguration des Betriebssystems auch per
Maus(doppel)klick.
• Sicherheit
Bei signierten jar-Dateien kann sich der Anwender Gewissheit über den Urheber verschaf-
fen und der Software entsprechende Rechte einräumen.1
• Versionsangaben für Pakete
In einem Archiv kann man Hersteller- und Versionsangaben zu den enthaltenen Paketen un-
terbringen.2
Mit den beiden zuletzt genannten Vorteilen können wir uns in diesem Manuskript aus Zeitgründen
nicht beschäftigen.
Eine traditionelle Archivdatei kann beliebig viele Pakete enthalten. Damit die dortigen class-
Dateien vom Compiler und von der JVM gefunden werden, muss die Archivdatei analog zu einem
Dateiordner mit Paketen in den Suchpfad für class-Dateien aufgenommen werden (vgl. Abschnitte
3.4.2 und 6.1.2.1), z. B. über die Umgebungsvariable CLASSPATH:

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

6.1.3.2 Archivdateien mit dem JDK-Werkzeug jar erstellen


Zum Erstellen und Verändern von Java-Archivdateien kann das JDK-Werkzeug jar.exe verwendet
werden. Wir erstellen eine Archivdatei mit den class-Dateien im Paket demopack und im Unterpa-
ket demopack.sub1:

 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.

6.1.3.3 Archivdateien verwenden


Um ein Archiv mit seinen Paketen als Klassenbibliothek in verschiedenen Projekten nutzen zu kön-
nen, kann es in den Suchpfad des Compilers bzw. Interpreters für class-Dateien aufgenommen wer-
den. Befindet z. B. die eben erstellte Archivdatei demarc.jar im Ordner U:\Eigene
Dateien\Java\lib, dann kann die Klasse demopack.Main in einem Konsolenfenster mit beliebi-
gem aktuellem Ordner folgendermaßen gestartet werden:
>java -cp "U:\Eigene Dateien\Java\lib\demarc.jar" demopack.Main
Analog zur -cp - Option in den Werkzeugaufrufen kann eine Archivdatei in die CLASSPATH-
Umgebungsvariable des Betriebssystems aufgenommen werden, z. B.:

Danach lässt sich das obige Startkommando vereinfachen:


Abschnitt 6.1 Pakete 351

>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.

6.1.3.4 Ausführbare jar-Dateien


Um eine als Anwendung ausführbare, traditionelle jar-Datei zu erstellen, nimmt man die ge-
wünschte Startklasse in das Archiv auf. Diese Klasse muss bekanntlich eine Methode main() mit
folgendem Definitionskopf besitzen:
public static void main(String[] args)
Außerdem muss die Klasse im sogenannten Manifest des Archivs, dem wir bisher keine Beachtung
geschenkt haben, als Main-Class eingetragen werden. Das Manifest befindet sich in der Datei
MANIFEST.MF, die das jar-Werkzeug im Archiv-Ordner META-INF anlegt, z. B.:

Im jar-Aufruf kann man eine Textdatei mit Manifestinformationen übergeben. Um z. B. im


PackDemo-Projekt (Variante von Abschnitt 6.1.1.4) die Startklasse Main im Paket demopack aus-
zuzeichnen, legt man eine Textdatei an, die folgende Zeile und eine anschließende Leerzeile (!)
enthält:

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

6.1.3.5 Archivunterstützung in IntelliJ


Obwohl die oben beschriebenen jar-Kommandos relativ übersichtlich sind, sollte in der Regel die
Erstellung der zur Auslieferung von Programmen und Bibliotheken benötigten Archivdateien von
der Entwicklungsumgebung erledigt werden. Wir öffnen das im Abschnitt 6.1.1.4 erstellte Projekt
PackDemo, wählen den Menübefehl
File > Project Structure > Artifacts
und bereiten im folgenden Fenster

die Erstellung einer (ausführbaren) jar-Datei vor:


• Nach einem Klick auf das Pluszeichen entscheiden wir uns dafür, dass aus dem aktuellen
Modul (im Sinn von IntelliJ) ein Archiv entstehen soll:

• 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:

6.1.3.6 Ausführbare jar-Datei für ein Projekt mit OpenJFX 8


Wenn mit dem Betriebssystem die Behandlung von jar-Dateien passend vereinbart wird, klappt
sogar der Start per Maus(doppel)klick auf die Archivdatei. Unter Windows sind dazu folgende Re-
gistry-Einträge geeignet:
Abschnitt 6.1 Pakete 355

[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

die Erstellung einer ausführbaren jar-Datei vor:


• Nach einem Klick auf das Pluszeichen entscheiden wir uns dafür, aus dem aktuellen Modul
(im Sinn von IntelliJ) eine JavaFX application entstehen soll:

• 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):

und 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
Abschnitt 6.1 Pakete 357

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.

Total time: 0 seconds


wenn der Projektname deutsche Umlaute enthält, haben wir für das JavaFX-Projekt eine englische
Bezeichnung gewählt (siehe Abschnitt 4.9.1).
Nach dem Auspacken der jar-Datei zeigt sich für die Produktion aus unserem Projekt, dass der es-
sentielle Ordner META-INF fehlt (vgl. Abschnitt 6.1.3.4 zum korrekten Inhalt einer jar-Datei):

Wir legen ihn an

und erstellen darin eine Textdatei mit dem Namen MANIFEST.MF

und mit dem folgenden Inhalt:


Manifest-Version: 1.0
Created-By: JavaFX Packager
Implementation-Vendor:
Implementation-Title:
Implementation-Version:
Main-Class: sample.Main
Class-Path:
Permissions: sandbox
JavaFX-Version: 8u312
358 Kapitel 6 Pakete und Module

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

• Eigenständige Anwendungen mit bedarfsgerechter Laufzeitumgebung


Selbstverständlich ist auch die Standardbibliothek seit Java 9 modular aufgebaut. Wie mit
dem Kommando
java --list-modules
zu ermitteln ist, besteht z. B. das API im OpenJDK 17 aus 71 Modulen. Das neue JDK-
Werkzeug jlink macht es möglich, ein Programm mit einer angepassten Laufzeitumgebung
bestehend aus den tatsächlich benötigten Modulen zu erstellen. Der Kunde erhält ein selb-
ständig ausführbares Programm mit relativ schlankem Lieferumfang und einem reduzierten
Risiko, von zukünftig entdeckten Sicherheitslücken im Java-API betroffen zu sein. Enthält
ein Programm mit angepasster modularer Laufzeitumgebung aber doch ein Sicherheitsprob-
lem, dann ist der Programmanbieter für die Update-Versorgung verantwortlich. Die Verant-
wortung und der Aufwand können also nicht mehr auf die OpenJDK-Anbieter abgewälzt
werden.
• Bessere Performanz beim Laden von Klassen
Jedes Modul deklariert (explizit oder implizit) seine Abhängigkeiten von anderen Modulen,
sodass nur wenige Module nach einem Paket bzw. nach einer Klasse durchsucht werden
müssen. Vor Java 9 musste der gesamte Klassenpfad durchsucht werden.
Die am häufigsten formulierte Kritik am JPMS betrifft die fehlende Versionsunterstützung. Bei der
Deklaration einer Abhängigkeit von einem anderen Modul ist keine Versionsangabe möglich. Im
Moduldeskriptor und im Namen einer modularen Archivdatei (siehe Abschnitte 6.2.3 und 6.2.6)
lässt sich eine Version angeben, die aber keine steuernde Wirkung hat. Die Verwaltung von Modul-
Versionen wird explizit den Erstellungswerkzeugen (wie z. B. Maven, Gradle) überlassen, was in
der quasi-offiziellen JPMS-Dokumentation (Reinhold 2016, Abschnitt 1.1) unmissverständlich zum
Ausdruck kommt:
A module’s declaration does not include a version string, nor constraints upon the version strings
of the modules upon which it depends. This is intentional: It is not a goal of the module system
to solve the version-selection problem, which is best left to build tools and container applica-
tions.
Ein Modul im JPMS enthält:
• Eine Moduldeklaration
Die Moduldeklaration befindet sich in einer Java-Quellcodedatei namens module-info.java.
Sie enthält den Namen des Moduls und deklariert u. a. die exportierten Pakete sowie die Ab-
hängigkeiten von anderen Modulen (siehe Abschnitt 6.2.1).
• Pakete
Gelegentlich sind Module sinnvoll, die keine Pakete enthalten, sondern nur transitive Abhän-
gigkeitsdeklarationen (siehe Abschnitt 6.2.1.2).
• Optional auch Daten (z. B. Medien, Sprachversionen von Texten)
Joshua Bloch, ein profunder Kenner der Java-Plattform und Mitentwickler der Standardbibliothek,
beurteilt in seinem zuletzt 2018 aufgelegten Standardwerk zur Java-Programmierung den aktuellen
Nutzen des Modulsystems zurückhaltend (S. 77):
It is too early to say whether modules will achieve widespread use outside of the JDK itself. In
the meantime, it seems best to avoid them unless you have a compelling need.
360 Kapitel 6 Pakete und Module

6.2.1 Moduldeklarationsdatei module-info.java


In der Datei module-info.java deklariert ein Modul:
• seinen Namen
• seine Anhängigkeiten von anderen Modulen durch requires-Deklarationen
• seine exportierten (für andere Module zugänglichen) Pakete durch exports-Deklarationen
• die Verwendung bzw. Implementation von Diensten durch uses- bzw. provides-
Deklarationen
• die Öffnung von Paketen für die Reflexion zur Laufzeit durch opens-Deklarationen
In der Moduldeklarationsdatei folgt auf das einleitende Schlüsselwort module der Name und ein
durch geschweifte Klammern begrenzter Block, z. B.:
module de.uni_trier.zimk.matrain {
requires transitive de.uni_trier.zimk.util;
exports de.uni_trier.zimk.matrain.br;
}
Die Moduldeklarationsdatei module-info.java wird vom Compiler in den sogenannten Modul-
deskriptor module-info.class übersetzt. Eine Behandlung als normale Bytecode-Datei wird schon
durch den Dateinamen verhindert, der wegen des Bindestrichs gegen die Konventionen bzw. Regeln
für Dateien mit Klassen oder Schnittstellen verstößt.

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

6.2.1.4 uses- und provides-Deklaration


Bei großen Software-Systemen ist eine lose Kopplung von kooperierenden Klassen erwünscht, so-
dass erst zur Laufzeit (eventuell aufgrund einer Benutzerentscheidung) zwischen mehreren Klassen,
die eine bestimmte Dienstleistung erbringen können, gewählt wird. Zur Laufzeit wird die Suche
nach einem Service-Provider durch die API-Klasse java.util.ServiceLoader unterstützt.
Wenn ein Modul einen Dienst benötigt, der in der Regel über eine bestimmte Schnittstelle (mit abs-
trakt definierten Handlungskompetenzen, siehe Kapitel 9) definiert ist, dann wird dieser Dienst
(diese Schnittstelle) in der Moduldeklaration per uses-Deklaration angemeldet. Als Beispiel be-
trachten wir das Modul java.sql aus dem Java-API, das einen Treiber benötigt, um mit einer SQL-
Datenbank zu kooperieren:
module java.sql {
requires transitive java.logging;
requires transitive java.xml;

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

Wir übernehmen den Simput-Quellcode (samt import-Deklarationen) aus der Datei


...\BspUeb\Einleitung\Bruchaddition\Konsole\Simput.java
und ergänzen am Anfang eine package-Deklaration:
package de.uni_trier.zimk.util.conio;

import java.util.*;
import java.io.*;

public class Simput {


. . .
}
Der Einfachheit halber besitzen die Module und Pakete im Demonstrationsbeispiel einen be-
schränkten Umfang:
• Jedes Modul enthält nur ein Paket.
• Jedes Paket enthält nur eine Klasse.
In einer realen modularen Anwendung enthalten die Module mehrere Pakete und die Pakete jeweils
mehrere Typen (Klassen und Schnittstellen).
Das Modul de.uni_trier.zimk.matrain enthält im Paket de.uni_trier.zimk.matrain.br
die altbekannte Klasse Bruch. Die Datei module-info.java wurde in früheren Abschnitten schon
als Beispiel verwendet:
module de.uni_trier.zimk.matrain {
requires transitive de.uni_trier.zimk.util;
exports de.uni_trier.zimk.matrain.br;
}
Weil die Abhängigkeit von de.uni_trier.zimk.util als transitiv erklärt wird, überträgt sie
sich auf Module, die von de.uni_trier.zimk.matrain abhängen.
Wir übernehmen den Quellcode der Klasse Bruch aus
...\BspUeb\Klassen und Objekte\Bruch\B3 (mit Konstruktoren)\Bruch.java
und ergänzen am Anfang eine package-Deklaration sowie eine import-Deklaration für das Paket
de.uni_trier.zimk.util.conio mit der Klasse Simput:
package de.uni_trier.zimk.matrain.br;

import de.uni_trier.zimk.util.conio.Simput;

public class Bruch {


. . .
}
Das Hauptmodul de.uni_trier.zimk.ba des Bruchadditionsprogramms enthält ein gleichna-
miges Paket mit der Startklasse. In der Modul-Deklarationsdatei wird die Abhängigkeit von
de.uni_trier.zimk.matrain erklärt:
module de.uni_trier.zimk.ba {
requires de.uni_trier.zimk.matrain;
}
366 Kapitel 6 Pakete und Module

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();

System.out.print("\nIhre Zufriedenheit mit der Software (1-5): ");


int zuf = Simput.gint();
System.out.println("Verstanden: " + zuf);
}
}

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

6.2.3 Übersetzung in ein explodiertes Modul und den Moduldeskriptor


Da wir momentan der Klarheit halber direkt mit den OpenJDK-Werkzeugen arbeiten, rufen wir zum
Übersetzen des Quellcodes der drei Module de.uni_trier.zimk.util,
de.uni_trier.zimk.matrain und de.uni_trier.zimk.ba den OpenJDK 17 - Compiler
javac.exe auf.1 Bei der Übersetzung eines Moduls entstehen:
• Der Moduldeskriptor module-info.class
• Die class-Dateien der Pakethierarchie
Einen Dateiverzeichnisbaum mit den class-Dateien eines Moduls bezeichnet man als explodiertes
Modul. Sein Hauptordner trägt per Konvention den Namen des Moduls.
Im folgenden javac-Kommando2
>javac -encoding utf8 -d expmods --module-source-path src
--module de.uni_trier.zimk.util,de.uni_trier.zimk.matrain,de.uni_trier.zimk.ba

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,

über den folgenden Aufruf des Java-Starters2

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

>java --module-path expmods --module


de.uni_trier.zimk.ba/de.uni_trier.zimk.ba.Bruchaddition
und verwenden dabei zwei Optionen:
• Modulpfad (siehe Abschnitt 6.2.4)
• Startmodul mit Startklasse
Über die Option --module (Kurzform -m) gibt man den Namen des Hauptmoduls und da-
hinter, durch einen Schrägstrich getrennt, den vollqualifizierten Namen der Startklasse an.
Hier ist die Kurzform des Startkommandos zu sehen:
>java -p expmods -m de.uni_trier.zimk.ba/de.uni_trier.zimk.ba.Bruchaddition

6.2.6 Modulare jar-Dateien


Aus einem explodierten Modul wird in aller Regel eine modulare jar-Datei erstellt. Sie unterschei-
det sich von einer herkömmlichen jar-Datei (siehe Abschnitt 6.1.3) nur durch die Anwesenheit des
Moduldeskriptors module-info.class im Wurzelverzeichnis.
Die Erstellung der modularen jar-Datei zu einem Modul erledigt man mit dem OpenJDK-
Hilfsprogramm jar.1 Für uns sind folgende Programmoptionen relevant:
• --create
Es soll eine modulare jar-Datei angelegt werden.
• --file
Pfad und Dateiname für die anzulegende jar-Datei werden festgelegt. Der Ausgabeordner
muss existieren. Während ein Modulname keine Version enthalten darf, ist er im Namen der
modularen Archivdatei erlaubt.
• --module-version
Das Modul erhält eine Version, die im Moduldeskriptor module-info.class eingetragen
wird, aber lediglich als beschreibendes Attribut fungiert.
• --main-class
Enthält das Modul eine zu startende Hauptklasse, dann muss diese über ihren vollqualifizier-
ten Namen bekanntgegeben werden. Es wird ein Attribut im Moduldeskriptor module-
info.class eingetragen. Außerdem wird ein Eintrag in der Datei MANIFEST.MF vorge-
nommen.
• -C
Hier ist das Wurzelverzeichnis des Moduls anzugeben (mit der Datei module-info.class).
Die Option muss groß geschrieben werden. Wenn sie fehlt, wird der aktuelle Ordner ange-
nommen.
• .
Am Ende des Kommandos ist unbedingt ein Punkt erforderlich.

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

Im Bruchadditionsbeispiel arbeiten wir mit einem Konsolenfenster, dessen aktuelles Verzeichnis


u. a. den Ordner expmods mit den explodierten Modulen enthält:

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

Option Kurzform Beschreibung


--module-path pfad -p Modulpfad
Alle im Modulpfad befindlichen jar-Dateien müs-
sen vom modularen Typ sein, wobei auch die soge-
nannten automatischen Module erlaubt sind. Dies
sind traditionelle jar-Dateien mit einem Dateina-
men, der sich als Modulname in das JPMS einfügt
(siehe Abschnitt 6.2.9.1).
--module modul/hauptklasse -m Zu startende Klasse
Es ist der Modulname anzugeben, also nicht etwa
der Name der modularen jar-Datei, der zusätzliche
Versionsangaben enthalten kann.
Wenn beim Erstellen der modularen jar-Datei über
die Option --main-class eine Hauptklasse deklariert
wurde, dann muss diese im Startkommando nicht
genannt werden.
In einem Konsolenfenster, dessen aktueller Ordner u. a. den Wurzelordner mods mit den modularen
jar-Dateien enthält, kann das Beispielprogramm folgendermaßen gestartet werden:
• Langform:
>java --module-path mods --module
de.uni_trier.zimk.ba/de.uni_trier.zimk.ba.Bruchaddition
• Kurzform (ohne Nennung der Hauptklasse):
>java -p mods -m de.uni_trier.zimk.ba
Wird z. B. de.uni_trier.zimk.util-1.0.jar nach U:\Eigene Dateien\Java\mlib verschoben, muss
der Modulpfad im Startkommando angepasst werden:
>java -p mods;"U:\Eigene Dateien\Java\mlib" -m de.uni_trier.zimk.ba
In einem PowerShell-Fenster unter Windows muss ein mehrelementiger Modulpfad durch Anfüh-
rungszeichen begrenzt werden, damit das Semikolon nicht als Kommandoterminator missverstan-
den wird, z. B.:
>java -p "mods;U:\Eigene Dateien\Java\mlib" -m de.uni_trier.zimk.ba
Ist zu einer modularen jar-Datei kein Quellcode vorhanden, kann man den Inhalt des Modul-
deskriptors module-info.class vom JDK-Werkzeug jar über die Option --describe-module anzei-
gen lassen, z. B.:
>jar --describe-module --file=mods\de.uni_trier.zimk.matrain-1.0.jar
Für das modulare Archiv de.uni_trier.zimk.matrain-1.0.jar, das wir eben erstellt haben, resultiert
die folgende Ausgabe:
[email protected]
jar:file:///U:/Eigene%20Dateien/Java/BspUeb/Pakete%20und%20Module/Bruchaddition/OpenJ
DK/mods/de.uni_trier.zimk.matrain-1.0.jar/!module-info.class
exports de.uni_trier.zimk.matrain.br
requires de.uni_trier.zimk.util transitive
requires java.base mandated

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

6.2.7 Unterstützung für Java-Module in IntelliJ IDEA


Um das im bisherigen Verlauf von Abschnitt 6.2 als Beispiel verwendete Bruchadditionsprogramm
bestehend aus den Modulen de.uni_trier.zimk.util, de.uni_trier.zimk.matrain und
de.uni_trier.zimk.ba mit IntelliJ zu erstellen, legen wir ein neues Java-Projekt mit dem
OpenJDK 17 als Project SDK an:

Diesmal verzichten wir auf eine Projekt-Vorlage:

Infolgedessen wird kein Base package erfragt:

Im Project-Fenster resultiert der folgende Ausgangszustand:


374 Kapitel 6 Pakete und Module

6.2.7.1 Modul de.uni_trier.zimk.util


Nach
File > Project Structure > Modules
legen wir ein neues Modul im Sinne von IntelliJ an, aus dem letztlich das JPMS - Modul
de.uni_trier.zimk.util entstehen soll:
• Wir klicken auf das Pluszeichen über dem Projektnamen Bruchaddition
• und wählen aus dem Pop-Up-Menü das Item New Module:

• Weil zunächst ein IntelliJ-Modul entsteht, ist ein Modul SDK zu wählen:

• Wir starten mit dem Modul de.uni_trier.zimk.util:


Abschnitt 6.2 Module 375

• 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

Am Anfang der Simput-Quellcodedatei fügt IntelliJ die passende package-Deklaration ein:


package de.uni_trier.zimk.util.conio;

public class Simput {


}
Wir übernehmen den im Abschnitt 6.2.2 ausgehend von
...\BspUeb\Einleitung\Bruchaddition\Konsole\Simput.java
erstellten Simput-Quellcode und erhalten als neuen Stand im Project-Fenster:

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

Schließlich ergänzen wir eine exports-Deklaration für das Paket de.uni_trier.zimk.util.conio


(vgl. Abschnitt 6.2.1.3):
module de.uni_trier.zimk.util {
exports de.uni_trier.zimk.util.conio;
}

6.2.7.2 Modul de.uni_trier.zimk.matrain


Nun legen wir analog zu Abschnitt 6.2.7.1 über
File > Project Structure > Modules
ein neues Modul im Sinne von IntelliJ an, aus dem letztlich das JPMS-Modul
de.uni_trier.zimk.matrain entstehen soll.
Im Fenster Project Structure zeigt IntelliJ eine Gruppe bestehend aus den beiden Modulen mit
dem Namensanfang de.uni_trier.zimk:

Im Project-Fenster resultiert der folgende Zwischenstand:

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

und ergänzen eine requires-Deklaration für das Paket de.uni_trier.zimk.util:


module de.uni_trier.zimk.matrain {
requires transitive de.uni_trier.zimk.util;
}
Der von IntelliJ durch rote Farbe signalisierte Fehler besteht darin, dass sich das IntelliJ-Modul
de.uni_trier.zimk.util nicht in der IntelliJ-Abhängigkeitsliste des Moduls
de.uni_trier.zimk.matrain befindet. Wir lassen das Problem von IntelliJ per QuickFix be-
heben:

Es ist zu beachten, dass es sich bei der Abhängigkeitsliste um ein Konzept der Entwicklungsumge-
bung IntelliJ handelt, nicht um ein JPMS-Konzept:

Um das Paket de.uni_trier.zimk.matrain.br im Modul de.uni_trier.zimk.matrain


anzulegen, wählen wir aus dem Kontextmenü zum src-Ordner des Moduls den Befehl
New > Package
und vergeben im folgenden Fenster den gewünschten Namen:

Im Paket de.uni_trier.zimk.matrain.br erstellen wir die Klasse Bruch und übernehmen


den im Abschnitt 6.2.2 ausgehend von
...\BspUeb\Klassen und Objekte\Bruch\B3 (mit Konstruktoren)\Bruch.java
Abschnitt 6.2 Module 379

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;
}

6.2.7.3 Hauptmodul de.uni_trier.zimk.ba


Wir legen analog zu Abschnitt 6.2.7.1 über
File > Project Structure > Modules
ein neues Modul im Sinne von IntelliJ an, aus dem letztlich das Java Hauptmodul
de.uni_trier.zimk.ba der Anwendung entstehen soll.
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
380 Kapitel 6 Pakete und Module

und ergänzen eine requires-Deklaration für das Paket de.uni_trier.zimk.matrain:


module de.uni_trier.zimk.ba {
requires de.uni_trier.zimk.matrain;
}
Der von IntelliJ durch rote Farbe signalisierte Fehler besteht darin, dass sich das IntelliJ-Modul
de.uni_trier.zimk.matrain nicht in der IntelliJ-Abhängigkeitsliste des Moduls
de.uni_trier.zimk.ba befindet. Wir lassen das Problem von IntelliJ per QuickFix beheben:

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();

System.out.print("\nIhre Zufriedenheit mit der Software (1-5): ");


int zuf = Simput.gint();
System.out.println("Verstanden: " + zuf);
}
}

Im Project-Fenster zeigt sich nun das folgende Bild:


Abschnitt 6.2 Module 381

Wir ergänzen über


Add Configuration > + > Application
noch eine Run-Konfiguration

und können das Programm endlich starten:


382 Kapitel 6 Pakete und Module

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

Das komplette IntelliJ-Projekt befindet sich im Ordner:


...\BspUeb\Pakete und Module\IntelliJ\Bruchaddition

6.2.8 Eigenständige Anwendungen mit maßgeschneiderter Laufzeitumgebung


Ab Java 9 gesellt sich als neue Phase der Anwendungserstellung zur Übersetzungsphase (basierend
auf dem Compiler javac.exe) und der Ausführungsphase (basierend auf der JVM) eine optionale
Bindungs- bzw. Link-Phase (basierend auf dem neuen JDK-Werkzeug jlink). Dabei wird ein Pro-
gramm mit einer angepassten modularen Laufzeitumgebung (engl. custom modular runtime im-
age) bestehend aus den tatsächlich benötigten API-Modulen verbunden. Der Kunde erhält ein selb-
ständig ausführbares Programm, das auf seinem Rechner keine JVM voraussetzt. Dank der maßge-
schneiderten (und somit reduzierten) modularen Laufzeitumgebung hält sich der Lieferumfang in
Grenzen.
Außerdem besteht für das ausgelieferte Programm eine reduzierte Gefahr, von zukünftig entdeckten
Sicherheitslücken im Java-API betroffen zu sein. Wenn es doch passiert, muss allerdings der Pro-
grammanbieter ein Update ausliefern. Während für die von Oracle oder von einem OpenJDK-
Distributor stammende komplette Java-Laufzeitumgebung auf einem Kundenrechner in der Regel
der jeweilige Anbieter mehr oder weniger zeitnah und kundenfreundlich Updates zur Verfügung
stellt, ist für eine Anwendung mit eigenständiger Laufzeitumgebung der Programmanbieter verant-
wortlich. Auf diese Verantwortungsübertragung weist die Firma Oracle in der Online-
Dokumentation zum jlink-Werkzeug nachdrücklich hin:2
Developers are responsible for updating their custom runtime images.
Wir erproben die Erstellung einer eigenständigen Distribution anhand des Bruchadditionsbeispiels
bestehend aus den Modulen de.uni_trier.zimk.ba, de.uni_trier.zimk.matrain und
de.uni_trier.zimk.util, die sich als modulare jar-Dateien im Ordner
...\BspUeb\Pakete und Module\JDK\Bruchaddition\mods
befinden. Das folgende jlink-Kommando zum Erstellen des eigenständigen Programms mit ange-
passter Laufzeitumgebung
>jlink -p "C:\Program Files\Java\OpenJDK-17\jmods;mods"
--add-modules de.uni_trier.zimk.ba --output distrib
setzt voraus, ...

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

• dass die OpenJDK-Version 17 in C:\Program Files\Java\OpenJDK-17 installiert ist,


• dass sich im aktuellen Verzeichnis des verwendeten Konsolenfensters der Ordner mods mit
den drei modularen jar-Dateien des Bruchadditionsbeispiels befindet,
• dass der bin-Unterordner der OpenJDK 17 - Installation im Windows-Pfad für ausführbare
Programme eingetragen ist.
Das Kommando verwendet drei Optionen:
• -p
Der Modulpfad enthält die API-Module im OpenJDK-Installationsordner und die anwen-
dungsspezifischen Module im Unterordner mods.
• --add-modules
Damit durch Auflösung der Abhängigkeiten der Graph mit den benötigten Modulen erstellt
werden kann, ist über die Option --add-modules ein Wurzelmodul (engl.: root module) zu
benennen.
• --output
Das selbständig ausführbare Programm soll im Unterordner distrib erstellt werden.
Der resultierende Ordner mit der angepassten modularen Laufzeitumgebung ist ca. 40 MB groß:

Durch das folgende Kommando mit Platzspartipps von Thorsten Horn1


>jlink -p "C:\Program Files\Java\OpenJDK-13\jmods;mods"
--add-modules de.uni_trier.zimk.ba --output distrib
--exclude-files *.diz --strip-debug --compress=2
reduziert sich der Platzbedarf auf ca. 25 MB. Ein kompletter OpenJDK-Ordner auf demselben Ver-
sionsstand ist ca. 300 MB groß.
Die angepasste modulare Laufzeitumgebung enthält auch den Starter java.exe, der als Argument
nur das Startmodul (aber keinen Modulpfad) benötigt:
>distrib\bin\java -m de.uni_trier.zimk.ba

6.2.9 Kompatibilität und Migration


Java kann seit der Version 9 beim Übersetzen und beim Ausführen von Programmen sowohl mit
dem Klassenpfad als auch mit dem Modulpfad umgehen, sodass kein Zwang für eine schnelle Um-
stellung bestehender Anwendungen auf das JPMS besteht. Über die anschließenden Erläuterungen
hinausgehende Hinweise zur Umstellung von vorhandenen Anwendungen auf das JPMS finden sich
im Oracle JDK Migration Guide (Oracle 2021b) und bei Reinhold (2016).

1
http://www.torsten-horn.de/techdocs/Jigsaw.html
384 Kapitel 6 Pakete und Module

6.2.9.1 Automatische Module


Noch wichtiger als die im Abschnitt 6.2.6 erwähnte Option, eine modulare jar-Datei in den traditi-
onellen Klassenpfad aufzunehmen, ist die „umgekehrte“ Möglichkeit, eine traditionelle jar-Datei
(ohne Moduldeskriptor!) in den Modulpfad aufzunehmen, also ohne nennenswerten Aufwand im
JPMS zu nutzen. Dabei entsteht ein sogenanntes automatisches Modul.
Das JPMS generiert den Modulnamen aus dem jar-Dateinamen, wobei die Namenserweiterung und
etwaige Versionsangaben entfernt werden, sodass z. B. aus dem Dateinamen mylib-3.01.jar der
Modulname mylib entsteht. Während ein traditioneller jar-Dateiname im Java-Quellcode nicht
auftaucht, kann der Name eines automatischen Moduls potentiell in den requires-Deklarationen
expliziter Module verwendet werden.
Eigenschaften eines automatischen Moduls:
• Es ist ein benanntes Modul und unterscheidet sich diesbezüglich vom dem im Abschnitt
6.2.9.2 zu beschreibenden unbenannten Modul, das alle per Klassenpfad zugänglichen Ty-
pen enthält. Ein automatisches Modul gehört aber nicht zu den sogenannten expliziten Mo-
dulen, die einen Moduldeskriptor besitzen.
• Alle Pakete in einem automatischen Modul gelten als exportiert. Sie können also von jedem
anderen Modul mit bestehender Abhängigkeitsbeziehung genutzt werden. Diese Abhängig-
keitsbeziehung besteht implizit bei anderen automatischen Modulen und beim unbenannten
Modul. Bei einem expliziten Modul muss sie per requires-Deklaration erklärt werden. Au-
ßerdem sind alle Pakete eines automatischen Moduls für die Laufzeit-Reflexion geöffnet.
• Ein automatisches Modul besitzt implizit eine Abhängigkeitsbeziehung zu allen anderen be-
nannten Modulen (explizit oder automatisch).
• Außerdem besitzt ein automatisches Modul im Gegensatz zu den expliziten Modulen eine
Leseberechtigung für das unbenannte Modul.
Vom Package Splitting - Verbot (siehe Abschnitt 6.2.4) sind auch die automatischen Module betrof-
fen, sodass zwei jar-Dateien mit identisch benannten Paketen nicht gleichzeitig aus dem traditionel-
len Klassenpfad als automatische Module in den Modulpfad übernommen werden können. In dieser
Lage hilft es, dass automatische Module neben benannten Modulen auch das unbenannte Modul
lesen dürfen.

6.2.9.2 Das unbenannte Modul


Auf dem Modulpfad befindliche traditionelle jar-Dateien werden zu automatischen Modulen und
können im Unterschied zu den expliziten Modulen (mit Moduldeskriptor) alle Typen auf dem tradi-
tionellen Klassenpfad sehen. Wird durch ein automatisches Modul ein Typ angefordert, der in kei-
nem benannten Modul zu finden ist, dann wird dieser Typ auf dem Klassenpfad gesucht. Die dorti-
gen Typen werden als Mitglieder des sogenannten unbenannten Moduls betrachtet, das eine ähnli-
che Rolle spielt wie das unbenannte Standardpaket.
Eigenschaften des unbenannten Moduls:
• Das unbenannte Modul besitzt eine implizite Abhängigkeitsbeziehung zu allen Modulen auf
dem Modulpfad, also zu jedem expliziten und zu jedem automatischen Modul, wobei auch
die API-Module einbezogen sind. Es kann also auf exportierte Pakete in benannten Modulen
zugreifen.
• Es exportiert sämtliche Pakete.
• Explizite Module können die Pakete des unbenannten Moduls allerdings nicht sehen. Auto-
matische Module können das unbekannte Modul hingegen nutzen.
• Die Typen im unbenannten Modul können sich gegenseitig uneingeschränkt sehen.
Abschnitt 6.2 Module 385

• 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).

6.2.9.4 Moduldeklaration zu vorhandenem Quellcode erstellen


Ist zu einer Menge zusammengehöriger Pakete der Quellcode vorhanden, bietet sich die Erstellung
eines expliziten Moduls an, wobei sich das JDK-Werkzeug jdeps nützlich macht. Als vertrautes,
wenn auch nicht sonderlich realistisches Beispiel verwenden wir die traditionelle Archivdatei
demarc.jar (vgl. Abschnitt 6.1.3.2) mit den Paketen demopack und demopack.sub1. Wir beför-
dern eine Abhängigkeit vom API-Paket javax.swing in eine Klasse des Pakets demopack ein
(durch Verwendung der Klasse JOptionPane):

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.*;

public class Main {


public static void main(String[] args) {
A a1 = new A(), a2 = new A();
. . .
JOptionPane.showMessageDialog(null, "jdeps-Demo");
}
}
Das Ergebnis (in der traditionellen Archivdatei demarcjop.jar) lassen wir durch den folgenden
jdeps-Aufruf auf Modul-Abhängigkeiten untersuchen:
>jdeps -s demarcjop.jar
Erwartungsgemäß ist demarcjop.jar (wie jede andere Java-Anwendung bzw. -Bibliothek) vom
API-Modul java.base abhängig. Außerdem besteht eine Abhängigkeit vom API-Modul
java.desktop, das u. a. das Paket javax.swing enthält:
demarcjop.jar -> java.base
demarcjop.jar -> java.desktop
Über die Option --generate-module-info kann man jdeps auffordern, die zur Erstellung eines ex-
pliziten Moduls benötigte Deklarationsdatei module-ionfo.java zu erstellen, z. B.:
>jdeps --generate-module-info . demarcjop.jar
Während jdeps genau die benötigten Abhängigkeiten in requires-Deklarationen umsetzt, kann das
Werkzeug natürlich nicht zwischen zu exportierenden und internen Paketen unterscheiden. Folglich
werden alle Pakete exportiert, und die Liste der exports-Deklarationen muss eventuell modifiziert
werden:
module demarcjop {
requires java.desktop;
exports demopack;
exports demopack.sub1;
}
Für den praxisgerechten Einsatz von jdeps sind weitere Informationen erforderlich, die z. B. die
Firma Oracle anbietet.1

6.2.10 Das modulare API der Java Standard Edition


Wie Sie längst wissen, gehören zur Java-Plattform zahlreiche Pakete, die Klassen und Schnittstellen
für wichtige Aufgaben der Programmentwicklung (z. B. Zeichenkettenverarbeitung, Netzwerkver-
bindungen, Datenbankzugriffe) enthalten. Die Zusammenfassung dieser Pakete bezeichnen wir als
Java-Standardbibliothek oder Java-API. Allerdings kann man eigentlich nicht von dem Java-API
sprechen, denn neben der Java Standard Edition (JSE), auf die wir uns im Kurs beschränken,
existieren noch die Java Enterprise Edition (JEE) mit großer Bedeutung für die Entwicklung von
Server-Anwendungen2 und die Java Micro Edition (JME) mit etwas unsicheren Zukunftsaussich-
ten (siehe Abschnitt 1.3.4).

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

6.3.1 Sichtbarkeit von Top-Level - Typen


Bisher haben wir uns überwiegend mit Top-Level - Typen beschäftigt, die nicht innerhalb des
Quellcodes anderer Typen definiert werden. In diesem Abschnitt geht es um den Zugriffsschutz für
solche Typen.1
Bei den Top-Level - Typen ist nur der Zugriffsmodifikator public erlaubt, sodass zwei Schutzstufen
möglich sind:
• Ohne Zugriffsmodifikator ist der Typ nur innerhalb des eigenen Pakets verwendbar.
• Durch den Zugriffsmodifikator public wird die Verwendung in allen berechtigten Modulen
erlaubt, z. B.:
package demopack;
public class A {
. . .
}

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:

Es wird eine sinnvolle QuickFix-Reparatur empfohlen:

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

Auch eine fehlende Exportdeklaration im Modul de.uni_trier.zimk.util wird von IntelliJ


erkannt und unmissverständlich als Problemursache benannt:

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.

6.3.2 Sichtbarkeit von Typmitgliedern


Zunächst einmal soll in Erinnerung gerufen werden, dass in Java der Zugriffsschutz nicht objekt-,
sondern klassenbezogen organisiert ist (Goll et al. 2000, S. 322). Ist z. B. eine Klasse A als public
definiert, dann können Objekte dieses Typs durch beliebige andere Klassen in berechtigten Modu-
len genutzt werden. Typischerweise sind die Felder der A-Klasse durch den Modifikator private
geschützt (Datenkapselung), während die Methoden der A-Klasse durch den Modifikator public der
Öffentlichkeit zur Verfügung gestellt werden.
Methoden einer Klasse B aus einem berechtigten Modul (ausgeführt von einem beliebigen B-Objekt
oder der B-Klasse selbst) ...
• können bei vorhandener Referenz ein A-Objekt a1 auffordern, eine Methode auszuführen,
• haben aber keinen Zugriff auf die Felder des A-Objekts.
Methoden der eigenen Klasse A (z. B. ausgeführt von einem A-Objekt a2) ...
• können bei vorhandener Referenz nicht nur das A-Objekt a1 auffordern, eine Methode aus-
zuführen,
• sondern haben auch vollen Zugriff auf die Felder von a1.
Abschnitt 6.3 Zugriffsschutz 391

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

Für Konstruktoren gilt:


• Bei expliziten Konstruktoren sind wie bei anderen Klassenmitgliedern die Modifikatoren
public, private und protected erlaubt. Ein als protected deklarierter Konstruktor darf im
eigenen Paket und von abgeleiteten Klassen in beliebigen Paketen aus berechtigten Modulen
genutzt werden.
• Der vom Compiler bereitgestellte Standardkonstruktor (vgl. Abschnitt 4.4.3) hat denselben
Zugriffsschutz wie die Klasse.

6.4 Übungsaufgaben zum Kapitel 6


1) Im folgenden Beispiel
class Worker {
void work() {
System.out.println("Geschafft!");
}
}

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?

2) Welche der folgenden Aussagen sind richtig bzw. falsch?


1. Die Namen von Paketen in JPMS-Modulen müssen mit dem Modulnamen beginnen.
2. Traditionelle jar-Dateien können als sogenannte automatische Module in den Modulpfad
aufgenommen werden.
3. Pakete eines Moduls sind per Voreinstellung (ohne exports-Deklaration) nur modul-intern
sichtbar.
4. Explizite Java - Module können auf class-Dateien im traditionellen Klassenpfad zugreifen.
7 Vererbung und Polymorphie
Im Manuskript war schon mehrfach davon die Rede, dass sich die Java-Klassen nicht auf einer
Ebene befinden, sondern in eine strenge Abstammungshierarchie eingeordnet sind. Nun betrachten
wir die Vererbungsbeziehung zwischen Klassen und die damit verbundenen Vorteile für die Soft-
ware-Entwicklung im Detail, wobei vor allem rationelle Wiederverwendung von vorhandener
Software zur Lösung neuer Aufgaben zu nennen ist.
Nachdem wir uns im Kapitel 6 mit der Organisation und Distribution von Java-Software mit Hilfe
von Paketen, Modulen und jar-Dateien beschäftigt haben, geht es im aktuellen Kapitel um zentrale
Konzepte der objektorientierten Programmierung (vgl. Abschnitt 4.1.1) und ihre Realisation in Ja-
va.

Modellierung realer Klassenhierarchien


Beim Modellieren eines Gegenstandsbereichs durch Klassen, die durch Eigenschaften (Instanz- und
Klassenvariablen) sowie Handlungskompetenzen (Instanz- und Klassenmethoden) gekennzeichnet
sind, müssen auch die Spezialisierungs- bzw. Generalisierungsbeziehungen zwischen real existie-
renden Objektsorten abgebildet werden. Eine Firma für Transportaufgaben aller Art mag ihre Nutz-
fahrzeuge folgendermaßen klassifizieren:
Nutzfahrzeug

Personentransporter LKW

Taxi Omnibus Möbelwagen Kranwagen Abschleppwagen

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

Die Vererbungstechnik in Java


In objektorientierten Programmiersprachen wie Java ist es weder sinnvoll noch erforderlich, jede
Klasse einer Hierarchie komplett neu zu definieren. Es steht eine mächtige und zugleich einfach
handhabbare Vererbungstechnik zur Verfügung: Man geht von der allgemeinsten Klasse aus und
leitet durch Spezialisierung neue Klassen ab, nach Bedarf in beliebig vielen Stufen. Eine abgeleitete
Klasse erbt alle Felder, Methoden und Mitgliedstypen ihrer Basis- oder Superklasse (jedoch keine
Konstruktoren). Auf die als private deklarierten Basisklassen-Member kann eine abgeleitete Klasse
allerdings nicht (direkt) zugreifen. Zur Lösung ihrer speziellen Aufgaben kann eine abgeleitete
Klasse Anpassungen bzw. Erweiterungen vornehmen, z. B.:
• zusätzliche Felder deklarieren
• zusätzliche Methoden definieren
• geerbte Methoden überschreiben, d. h. unter Beibehaltung der Signatur umgestalten
Ihre Konstruktoren muss eine abgeleitete Klasse neu definieren, wobei es aber leicht möglich ist,
einen Basisklassenkonstruktor zur Initialisierung von geerbten Instanzvariablen einzuspannen (siehe
Abschnitt 7.3).
In Java stammen alle Klassen von der Klasse Object aus dem Paket java.lang ab. Wird (wie bei
den meisten bisherigen Beispielen im Manuskript) in der Definition einer Klasse keine Basisklasse
angegeben, dann stammt sie auf direktem Wege von Object ab, anderenfalls indirekt. Bei der Im-
plementation in Java wird die oben dargestellte Figuren-Klassenhierarchie so eingehängt:
Object

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.

7.1 Definition einer abgeleiteten Klasse


Wir definieren im angekündigten Beispiel zunächst die Basisklasse Figur, die Instanzvariablen für
die X- und die Y-Position der linken oberen Ecke einer zweidimensionalen Figur sowie zwei Kon-
struktoren besitzt:
package de.uni_trier.zimk.figuren;

public class Figur {


private double xpos = 100.0, ypos = 100.0;

public Figur(double x, double y) {


if (x >= 0 && y >= 0) {
xpos = x;
ypos = y;
}
System.out.println("Figur-Konstruktor");
}

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 class Kreis extends Figur {


private double radius = 50.0;

public Kreis(double x, double y, double rad) {


super(x, y);
if (rad >= 0)
radius = rad;
System.out.println("Kreis-Konstruktor");
}

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

class FigurenDemo { Figur-Konstruktor


public static void main(String[] args) { Kreis-Konstruktor
Figur fig = new Figur(50.0, 50.0);
System.out.println();
Kreis krs = new Kreis(10.0, 10.0, 5.0);
}
}

7.2 Der Zugriffsmodifikator protected


In diesem Abschnitt wird anhand einer Variante des Figurenbeispiels der Effekt des bei Klassen-
mitgliedern erlaubten Zugriffsmodifikators protected demonstriert (vgl. Abschnitt 6.3.2). Wenn die
Basisklasse Figur die Instanzvariablen xpos und ypos als protected deklariert,
protected double xpos = 100.0, ypos = 100.0;
dann können Methoden abgeleiteter Klassen unabhängig von ihrer Paketzugehörigkeit direkt darauf
zugreifen. Dies geschieht in der neuen Kreis-Methode abstand(), die für einen beliebigen Punkt
im zweidimensionalen Koordinatensystem über den Satz von Pythagoras den Abstand zum Kreis-
mittelpunkt berechnet.1 Weil innerhalb eines Pakets die abgeleiteten Klassen dieselben Zugriffs-
rechte haben wie beliebige andere Klassen (vgl. Abschnitt 6.3), sorgen wir zu Demonstrationszwe-
cken dafür, dass die Basisklasse Figur und die abgeleitete Klasse Kreis zu verschiedenen Paketen
gehören.
package de.uni_trier.zimk.figuren.kreis;

import de.uni_trier.zimk.figuren.Figur;

public class Kreis extends Figur {


protected double radius = 50.0;

public Kreis(double x, double y, double rad) {


super(x, y);
if (rad >= 0)
radius = rad;
}

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

public double abstand(double x, double y) {


return Math.sqrt(Math.pow(xpos+radius-x, 2) + Math.pow(ypos+radius-y, 2));
}
}
Es ist zu beachten, dass die Kreis-Methode abstand() auf geerbte Instanzvariablen von Kreis-
Objekten zugreift. Es ist auch erlaubt, dass ein handelndes Kreis-Objekt auf die geerbten In-
stanzvariablen eines anderen Kreis-Objekts direkt zugreift. Auf das xpos-Feld eines Figur-
Objekts könnte eine Methode der Kreis-Klasse hingegen nicht direkt zugreifen.
Während Objekte aus abgeleiteten Klassen ihre geerbten protected-Elemente direkt ansprechen
können, haben paketfremde Klassen auf Elemente mit dieser Sichtbarkeit keinen Zugriff, z. B.:
import de.uni_trier.zimk.figuren.kreis.Kreis;

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);
}
}

7.3 Basisklassenkonstruktoren und Initialisierungsmaßnahmen


Abgeleitete Klassen erben die Basisklassenkonstruktoren nicht, können diese aber in eigenen Kon-
struktoren über das Schlüsselwort super aufrufen. Dieser Aufruf muss am Anfang eines Konstruk-
tors stehen. Dadurch ist es z. B. möglich, geerbte Instanzvariablen zu initialisieren, die in der Basis-
klasse als private deklariert wurden. Diese Konstellation war in der ursprünglichen Version des
Figurenbeispiels gegeben (siehe Abschnitt 7.1).
Wird in einem Konstruktor einer abgeleiteten Klasse kein Basisklassenkonstruktor explizit aufgeru-
fen, dann ruft der Compiler implizit den parameterfreien Konstruktor der Basisklasse auf. Fehlt ein
solcher, weil der Basisklassenprogrammierer einen eigenen, parametrisierten Konstruktor erstellt
und nicht durch einen expliziten parameterfreien Konstruktor ergänzt hat, dann protestiert der
Compiler. Um die folgende Reklamation von IntelliJ zu provozieren, wurde in der Klasse Figur
der parameterfreie Konstruktor auskommentiert:

Es gibt zwei offensichtliche Möglichkeiten, das Problem zu lösen:


• Im Konstruktor der abgeleiteten Klasse über das Schlüsselwort super einen parametrisierten
Basisklassenkonstruktor aufrufen.
• In der Basisklasse einen parameterfreien Konstruktor definieren.
Der parameterfreie Basisklassenkonstruktor wird auch vom Standardkonstruktor einer abgeleiteten
Klasse aufgerufen, sodass jede potentiell als Erblasser in Frage kommende Klasse einen parameter-
freien Konstruktor haben sollte.
Beim Erzeugen eines Unterklassenobjekts laufen folgende Initialisierungsmaßnahmen ab (vgl. Gos-
ling et al. 2021, Abschnitt 12.5):
398 Kapitel 7 Vererbung und Polymorphie

• 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.

7.4 Überschreiben und Überdecken

7.4.1 Überschreiben von Instanzmethoden


Eine Basisklassenmethode darf in einer abgeleiteten Klasse durch eine Methode mit gleichem Na-
men und gleicher Parameterliste (also mit gleicher Signatur, vgl. Abschnitt 4.3.4) überschrieben
werden, um ein spezialisiertes Verhalten zu realisieren. Es liegt übrigens keine Überschreibung vor,
wenn in der abgeleiteten Klasse eine Methode mit gleichem Namen, aber abweichender Parameter-
liste definiert wird. In diesem Fall sind die beiden Signaturen verschieden, und es handelt sich um
eine Überladung (siehe Abschnitt 4.3.4).
Um das Überschreiben von Instanzmethoden demonstrieren zu können, erweitern wir die Figur-
Basisklasse um eine Methode namens wo(), die die Position der linken oberen Ecke ausgibt:
Abschnitt 7.4 Überschreiben und Überdecken 399

package de.uni_trier.zimk.figuren;

public class Figur {


protected double xpos = 100.0, ypos = 100.0;

public Figur(double x, double y) {


if (x >= 0 && y >= 0) {
xpos = x;
ypos = y;
}
}

public Figur() {}

public void wo() {


System.out.println("\nOben links: (" + xpos + ", " + ypos + ") ");
}
}
In der Kreis-Klasse ist eine bessere Ortsangabenmethode realisierbar, weil hier auch die rechte
untere Ecke definiert ist:
package de.uni_trier.zimk.figuren;

public class Kreis extends Figur {


protected double radius = 50.0;

public Kreis(double x, double y, double rad) {


super(x, y);
if (rad >= 0)
radius = rad;
}

public Kreis() {}

public double abstand(double x, double y) {


return Math.sqrt(Math.pow(xpos+radius-x, 2) + Math.pow(ypos+radius-y, 2));
}

@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)

class FigurenDemo { Oben links: (50.0, 100.0)


public static void main(String[] ars) { Unten rechts: (100.0, 150.0)
Figur f = new Figur(10.0, 20.0);
f.wo();
Kreis k = new Kreis(50.0, 100.0, 25.0);
k.wo();
}
}

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 class Mint {


public int value;

public Mint(int value) {


this.value = value;
}

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.

7.4.2 Überdecken von statischen Methoden


Es ist erlaubt, in einer abgeleiteten Klasse eine statische Methode zu definieren, die die Signatur
einer statischen Basisklassenmethode besitzt (selber Name und selbe Parameterliste). Zur der im
letzten Abschnitt beschriebenen späten Entscheidung über die auszuführende Methode kommt es
aber auch dann nicht, wenn die statische Methode über eine Objektreferenz angesprochen wird, was
nicht empfehlenswert ist, aber erlaubt. In diesem Fall entscheidet der deklarierte Datentyp, nicht der
Laufzeittyp, z. B.:
402 Kapitel 7 Vererbung und Polymorphie

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.

7.4.3 Finalisierte Methoden


Gelegentlich ist es sinnvoll, die Flexibilität der objektorientierten Vererbungstechnik gezielt einzu-
schränken, um das Auftreten von Unterklassenobjekten zu verhindern, die ein essentielles Basis-
klassenverhalten auf unerwünschte Weise neu definieren. Um das Überscheiben einer Instanzme-
thode oder das Überdecken einer statischen Methode in abgeleiteten Klassen zu verbieten, setzt man
bei der Definition den Modifikator final.1 Dient etwa die Methode passwd() einer Klasse Acl
zum Abfragen eines Passwortes, will ihr Programmierer eventuell verhindern, dass passwd() in
einer von Acl abstammenden Klasse Bcl überschrieben wird. Ein guter Grund zum Finalisieren
besteht meist auch bei Methoden, die in einer initialisierenden Deklarationsanweisung, in einem
Instanzinitialisierer oder von einem Konstruktor aufgerufen werden.
Unsere Klasse Figur (siehe Abschnitt 7.4.1) könnte z. B. eine Methode oleck() zur Ausgabe der
oberen linken Ecke erhalten, die von abgeleiteten Klassen nicht geändert werden soll und daher als
final (endgültig) deklariert wird:
final public void oleck() {
System.out.print("\nOben links: (" + xpos + ", " + ypos + ") ");
}

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.

7.4.4 Felder überdecken


Wird in der abgeleiteten Klasse Spezial für eine Instanz- oder Klassenvariable ein Name verwen-
det, der bereits eine Variable der beerbten Klasse General bezeichnet, dann wird die Basisklassen-
variable überdeckt. Sie ist jedoch weiterhin vorhanden und kommt in folgenden Situationen zum
Einsatz:
• Von General geerbte Methoden verwenden weiterhin die General-Variable. In der Spe-
zial-Klasse implementierte Methoden (zusätzliche, überschreibende oder überdeckende)
greifen auf die Spezial-Variable zu.
• In der Spezial-Klasse implementierte Methoden können auf die General-Variable zugrei-
fen:
o auf eine überdeckte Instanzvariable durch das vorangestellte Schlüsselwort super
o auf eine überdeckte statische Variable durch den vorangestellten Klassennamen.
Im folgenden Beispielprogramm führt ein Spezial-Objekt eine General- und eine Spezial-
Methode aus, um den Zugriff auf eine überdeckte Instanzvariable zu demonstrieren:
Quellcode Ausgabe
// Datei General.java x in gm(): x-Gen
class General { xs in gm(): sx-Gen
String x = "x-Gen";
static String xs = "sx-Gen"; x in sm(): 333
void gm() { xs in sm(): 555
System.out.println("x in gm(): " + x);
System.out.println("xs in gm(): " + xs); super-x in sm(): x-Gen
} Basis-xs in sm(): sx-Gen
static void gms() {
System.out.println("xs in gms(): " + xs); xs in gms(): sx-Gen
} xs in sms(): 555
}
// Datei Spezial.java
class Spezial extends General {
int x = 333;
static int xs = 555;
void sm() {
System.out.println("x in sm(): " + x);
System.out.println("xs in sm(): " + xs);
System.out.println("\nsuper-x in sm(): " + super.x);
System.out.println("Basis-xs in sm(): " + General.xs);
}
static void sms() {
System.out.println("xs in sms(): " + xs);
}
}
// Datei Test.java
class Test {
public static void main(String[] args) {
Spezial sp = new Spezial();
sp.gm(); System.out.println(); sp.sm();
System.out.println();
Spezial.gms(); Spezial.sms();
}
}
404 Kapitel 7 Vererbung und Polymorphie

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.

7.5 Verwaltung von Objekten über Basisklassenreferenzen


Eine Basisklassenreferenzvariable darf die Adresse eines beliebigen Unterklassenobjektes aufneh-
men. Schließlich besitzt Letzteres die komplette Ausstattung der Basisklasse und kann z. B. dort
definierte Methoden ausführen. Ein Objekt steht nicht nur zur eigenen Klasse in der „ist-ein“-
Beziehung, sondern erfüllt diese Relation auch in Bezug auf die direkte Basisklasse sowie in Bezug
auf alle indirekten Basisklassen in der Ahnenreihe.
Andererseits verfügt ein Basisklassenobjekt in der Regel nicht über die Ausstattung von abgeleite-
ten (erweiterten bzw. spezialisierten) Klassen. Daher ist es sinnlos und verboten, die Adresse eines
Basisklassenobjektes in einer Unterklassen-Referenzvariablen abzulegen.
Über Referenzvariablen vom Typ einer gemeinsamen Basisklasse lassen sich also Objekte aus un-
terschiedlichen Klassen verwalten. Im Rahmen eines Grafikprogramms kommt vielleicht ein Array
mit dem Elementtyp Figur zum Einsatz, dessen Elemente auf Objekte aus der Basisklasse oder aus
einer abgeleiteten Klasse wie Kreis oder Rechteck zeigen:
Array fa mit Elementtyp Figur

fa[0] fa[1] fa[2] fa[3] fa[4]

Figur- Kreis- Figur- Kreis- Rechteck -


Objekt Objekt Objekt Objekt Objekt

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.6 Der instanceof-Operator


Muss für die Behandlung eines Objekts die Zugehörigkeit zu einer Klasse oder die Implementation
einer Schnittstelle (siehe Kapitel 9) vorausgesetzt werden, dann sollte diese Bedingung per in-
stanceof-Parameter überprüft werden. Dieser Operator wird erst jetzt präsentiert, weil seine Bedeu-
tung nach der gründlichen Erläuterung von Klassen und Vererbung weitaus besser zu verstehen ist
als im Zusammenhang mit den elementaren Sprachelementen (vgl. Abschnitt 3.5).
Im Beispiel aus dem Abschnitt 7.5 sollte für ein Element in Array fa die Kreis-Methode
gibRadius() nur dann aufgerufen werden, wenn per instanceof-Operator die Zugehörigkeit zu
dieser Klasse erfolgreich überprüft worden ist:
if (fa[i] instanceof Kreis)
System.out.println("Figur " + i + ": Radius = " + ((Kreis)fa[i]).gibRadius());
Seit Java 16 kann im rechten instanceof-Operanden zusätzlich zum fraglichen Typ eine sogenannte
Mustervariable angegeben werden, die nach einem erfolgreichen Typtest eine Kopie der Objektad-
resse im linken Operanden erhält. Im Beispiel kommt man dann ohne Typumwandlung aus:
if (fa[i] instanceof Kreis k)
System.out.println("Figur " + i + ": Radius = " + k.gibRadius());
Ist der logische Ausdruck zusammengesetzt, dann darf die Mustervariable nach ihrer Deklaration
im instanceof-Ausdruck auch in späteren Teilausdrücken auftreten, z. B.:
if (fa[i] instanceof Kreis k && k.gibRadius() > 0)
System.out.println("Umfang = " + 2 * Math.PI * k.gibRadius());
Wegen der Kurzschlussauswertung des && - Operators kann ein solcher zusammengesetzter logi-
scher Ausdruck keine NullPointerException verursachen.
Die Gültigkeit der Mustervariablen ist beschränkt auf:
• den logischen Ausdruck der if-Anweisung,
• die bedingt auszuführende Anweisung.
406 Kapitel 7 Vererbung und Polymorphie

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

Oben Links: (10.0, 20.0)

Oben Links: (50.0, 50.0)


Unten Rechts: (100.0, 100.0)

Wollen Sie zum Abschluss noch eine Figur oder einen Kreis erleben?
Waehlen Sie durch Abschicken von "f" oder "k": k

Oben Links: (100.0, 100.0)


Unten Rechts: (200.0, 200.0)
Radius: 50.0
Zum „Beweis“, dass tatsächlich eine späte Bindung stattfindet, darf im Beispielprogramm der Lauf-
zeittyp des Array-Elements fa[2] vom Benutzer festgelegt werden.
Wird in einem Programm zur Verwendung von geometrischen Objekten der allgemeine Datentyp
Figur genutzt, dann führen die zu diversen Figur-Unterklassen gehörigen Objekte beim Aufruf
einer Basisklassenmethode ihr artspezifisches Verhalten aus. Später können neu entwickelte Fi-
gur-Ableitungen einbezogen werden, ohne den Quellcode der bereits vorhandenen Klassen ändern
zu müssen. So sorgen Vererbung und Polymorphie für produktives Software-Recycling im Sinn des
Open-Closed - Prinzips (vgl. Abschnitt 4.1.1.3).
Eng verwandt mit der eben beschriebenen Basisklassen-Polymorphie ist die Interface-Polymorphie,
wobei als Datentyp für die flexiblen Referenzen an Stelle einer gemeinsamen Basisklasse ein Inter-
face steht, das alle beteiligten Klassen implementieren (siehe Abschnitt 9.4).

7.8 Abstrakte Methoden und Klassen


Um die eben beschriebene gemeinsame Verwaltung von Objekten aus diversen abgeleiteten Klas-
sen über Referenzvariablen von einem gemeinsamen Basisklassentyp nutzen und dabei artspezifisch
realisierte Methodenaufrufe realisieren zu können, müssen die betroffenen Methoden in der Basis-
klasse vorhanden sein. Wenn es für eine Methode in der Basisklasse keine sinnvolle Implementie-
rung gibt, erstellt man dort eine sogenannte abstrakte Methode:
• Man beschränkt sich auf den Methodenkopf und setzt dort den Modifikator abstract.
• Den Methodenrumpf ersetzt man durch ein Semikolon.
Im Figurenbeispiel erweitern wir die Klasse Kreis um eine Methode namens meldeInhalt()
zum Ermitteln des Flächeninhalts:
public double meldeInhalt() {
return Math.PI * radius*radius;
}
Außerdem erstellen wir die Klasse Rechteck und definieren auch hier eine Methode namens
meldeInhalt():
package de.uni_trier.zimk.figuren;

public class Rechteck extends Figur {


protected double breite = 50.0, hoehe = 50.0;

public Rechteck(double x, double y, double b, double h) {


super(x, y);
if (b >= 0 && h >= 0) {
breite = b;
hoehe = h;
}
}

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;

public abstract class Figur {


. . .
public abstract double meldeInhalt();
. . .
}
Enthält eine Klasse mindestens eine abstrakte Methode, dann handelt es sich um eine abstrakte
Klasse, und bei der Klassendefinition muss der Modifikator abstract vergeben werden.
Aus einer abstrakten Klasse kann man zwar keine Objekte erzeugen, aber andere Klassen ableiten.
Implementiert eine abgeleitete Klasse die abstrakten Methoden, dann lassen sich Objekte daraus
herstellen; anderenfalls ist sie ebenfalls abstrakt. Im Beispiel werden aus der nunmehr abstrakten
Klasse Figur die beiden konkreten Klassen Kreis und Rechteck abgeleitet.
Eine abstrakte Klasse eignet sich bestens als Datentyp, und Referenzen dieses Typs sind ja auch
unverzichtbar, wenn Objekte diverser Unterklassen polymorph verwaltet werden sollen. Das fol-
gende Programm:
import 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.

7.9 Das Liskovsche Substitutionsprinzip


In diesem Abschnitt geht es um eine auf den ersten Blick theoretisch wirkende, aber durchaus pra-
xisrelevante Klärung zur objektorientierten Vererbungsbeziehung. Das nach Barbara Liskov be-
nannte Substitutionsprinzip (dt.: Ersetzbarkeitsprinzip) verlangt von einer Klassenhierarchie
(Liskov & Wing 1999, S. 1):
Let (x) be a property provable about objects x of type T. Then ( y) should be true for objects
y of type S where S is a subtype of T.
Wird beim Entwurf einer Klassenhierarchie das Liskovsche Substitutionsprinzip (LSP) beachtet,
dann können Objekte einer abgeleiteten Klasse stets die Rolle von Basisklassenobjekten perfekt
übernehmen, d .h. u. a.:
• Das „vertraglich“ zugesicherte Verhalten der Basisklassenmethoden wird auch von den
(eventuell überschreibenden) Unterklassenvarianten eingehalten.
• Unterklassenobjekte werden bei Verwendung in der Rolle von Basisklassenobjekten nicht
beschädigt.
Eine Verletzung der Ersetzbarkeitsregel kann auch bei einfachen Beispielen auftreten, wobei oft
eine aus dem Anwendungsbereich stammende Plausibilität zum fehlerhaften Design verleitet. So ist
z. B. ein Quadrat aus mathematischer Sicht ein spezielles Rechteck. Definiert man in einer Klasse
für Rechtecke die Methoden skaliereX() und skaliereY() zur Änderung der Länge in X-
bzw. - Y-Richtung, so gehört zum „vertraglich“ zugesicherten Verhalten dieser Methoden:
• Bei einem Zuwachs in X-Richtung bleibt die Y-Ausdehnung unverändert.
• Verdoppelt man die Breite eines Objekts, dann verdoppelt sich auch der Flächeninhalt.
Die simple Tatsache, dass aus mathematischer Sicht jedes Quadrat ein Rechteck ist, rät offenbar
dazu, eine Klasse für Quadrate aus der Klasse für Rechtecke abzuleiten. In der neuen Klasse ist al-
lerdings die Konsistenzbedingung zu ergänzen, dass bei einem Quadrat stets alle Seiten gleich lang
bleiben. Um das Auftreten irregulärer (defekter) Objekte der Klasse Quadrat zu verhindern, wird
man z. B. die Methode skaliereX() so überschreiben, dass bei einer X-Modifikation automatisch
auch die Y-Ausdehnung angepasst wird. Damit ist aber der skaliereX() - Vertrag verletzt, wenn
ein Quadrat die Rechteckrolle übernimmt. Eine verdoppelte X-Länge führt nicht zur doppelten,
sondern zur vierfachen Fläche. Verzichtet man andererseits in der Klasse Quadrat auf das Über-
schreiben der Methode skaliereX(), dann ist bei den Objekten dieser Klasse die Konsistenzbe-
dingung identischer Seitenlängen massiv gefährdet. Offenbar haben Plausibilitätsüberlegungen zu
einer schlecht entworfenen Klassenhierarchie geführt.
Eine exakte Verhaltensanalyse zeigt, dass ein Quadrat in funktionaler Hinsicht eben doch kein
Rechteck ist. Es fehlt die für Rechtecke typische Option, die Ausdehnung in X- bzw. Y-Richtung
separat zu verändern. Diese Option könnte in einem Algorithmus, der den Datentyp Rechteck vo-
raussetzt, von Bedeutung sein. Es muss damit gerechnet werden, dass der Algorithmus irgendwann
(bei einer Erweiterung der Software) auf Objekte mit einem von Rechteck abstammenden Daten-
typ trifft. Passiert dies mit der Klasse Quadrat könnte es zu Problemen kommen, weil nach einer
Verdopplung der X-Ausdehnung der Flächeninhalt entgegen der Erwartung nicht auf das Doppelte,
sondern auf das Vierfache wächst.
410 Kapitel 7 Vererbung und Polymorphie

Um die Einhaltung des Substitutionsprinzips beurteilen zu können, bedarf es einer sorgfältigen


Analyse. Wenn etwa Objekte der Klasse Rechteck unveränderlich wären, wenn also die Metho-
den skaliereX() und skaliereY() in der Klassendefinition von Rechteck fehlen würden,
dann könnte die Klasse Quadrat sehr wohl als Spezialisierung von Rechteck definiert werden.
Java bietet gute Voraussetzungen für eine erfolgreiche objektorientierte Programmierung, kann aber
z. B. eine Verletzung des Substitutionsprinzips nicht verhindern.

7.10 Unerwünschte Abhängigkeiten durch Vererbung


Unter Software-Entwicklern hat es sich herumgesprochen, dass die Vererbung einerseits ein Segen
ist (rationelle Wiederverwendung von Software), andererseits aber auch ein Fluch, weil uner-
wünschte Abhängigkeiten zwischen Klassen resultieren.

7.10.1 Risiken für abgeleitete Klassen


Eine abgeleitete Klasse erbt oftmals viele Methoden der Basisklasse, die im günstigsten Fall über-
flüssig sind, eventuell aber auch Schadpotential besitzen. Ein bekanntes Negativbeispiel aus dem
Java-API ist die von Hashtable<Object, Object> abgeleitete Klasse Properties im Paket ja-
va.util:
public class Properties extends Hashtable<Object,Object> {
. . .
public synchronized Object setProperty(String key, String value) {
return put(key, value);
}
. . .
}
Um korrekt zu funktionieren, darf eine Kollektion aus der Klasse Properties als Einträge nur Paare
mit einem Namen und einem Wert vom Typ String enthalten (siehe Definition der Methode
setProperty()).1 Über die von der Klasse Hashtable<Object, Object> geerbte Methode put() kann
aber auch ein (Object, Object) - Paar eingeschmuggelt werden. In der Dokumentation zur Klasse
Properties wird auf die Gefahr hingewiesen:
Because Properties inherits from Hashtable, the put and putAll methods can be applied to a Properties
object. Their use is strongly discouraged as they allow the caller to insert entries whose keys or values are not
Strings. The setProperty method should be used instead. If the store or save method is called on a
"compromised" Properties object that contains a non-String key or value, the call will fail. Similarly, the call to
the propertyNames or list method will fail if it is called on a "compromised" Properties object that contains a
non-String key.
Im Beispiel bestand das Problem schon bei der Definition der abgeleiteten Klasse Properties. Es
kann aber auch durch eine spätere Erweiterung der Basisklasse auftreten.
Neben den unerwünschten Erbstücken besteht eine weitere Problemkonstellation für abgeleitete
Klassen in der Überschreibung von Methoden. Diese ist möglich bei Basisklassenmethoden mit der
Sichtbarkeit public oder protected, die nicht als final deklariert worden sind (vgl. Abschnitt 7.4.3).
Auch hier liegen wieder Segen (in Form der Polymorphie, siehe Abschnitt 7.7) und Fluch (in Form
einer Abhängigkeit von Implementierungsdetails der Basisklasse) nah beieinander. Bloch (2018, S.
87ff) beschreibt eine Ableitung der zur Mengenverwaltung dienenden Kollektionsklasse
HasSet<E> aus dem Java Collections Framework.2 Blochs Klasse soll die eingefügten (und even-
tuell teilweise später wieder gelöschten) Elemente zählen. Zu diesem Zweck werden die geerbten
Methoden add() und addAll() so überschrieben, dass ein privates Feld geeignet inkrementiert wird.

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.

7.10.2 Nachteile für potentielle Basisklassen


Im bisherigen Verlauf von Abschnitt 7.10 wurden Risiken für abgeleitete Klasse behandelt und
Empfehlungen zur Vermeidung gegeben. Aber auch für den Entwickler einer potentiellen Basis-
klasse entstehen Nachteile aus seiner Entscheidung, die Ableitung nicht zu verhindern:
• Um Fehler beim Ableiten (insbesondere beim Überschreiben von Methoden) zu vermeiden,
müssen Implementationsdetails offengelegt werden, die nach der reinen objektorientierten
Lehre eigentlich verborgen bleiben sollten, um spätere Weiterentwicklungen zu ermöglichen
(Bloch 2018, S. 93f). Eine ableitbare Klasse muss in ihrer Dokumentation jede Verwendung
einer überschreibbaren Methode dokumentieren. Eine offengelegte Implementation kann
nicht mehr geändert werden.
412 Kapitel 7 Vererbung und Polymorphie

• 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.

7.11 Erzwungene und optionale Einschränkungen beim Vererben


In der Java-Vererbungstechnik bestehen erzwungene und optionale Einschränkungen.

7.11.1 Keine Mehrfachvererbung


In Java ist generell keine Mehrfachvererbung möglich: Man kann also in einer Klassendefinition
hinter dem Schlüsselwort extends nur eine Basisklasse angeben. Im Sinne einer realitätsnahen Mo-
dellierung wäre eine Mehrfachvererbung gelegentlich durchaus wünschenswert. So könnte z. B. die
Klasse Receiver von den Klassen Tuner und Amplifier erben. Man hat auf die in anderen Pro-
grammiersprachen (z. B. C++) erlaubte Mehrfachvererbung bewusst verzichtet, um von vornherein
den kritischen Fall auszuschließen, dass eine abgeleitete Klasse gleichnamige Instanzvariablen von
mehreren Klassen erbt, woraus leicht Mehrdeutigkeiten und Fehler resultieren können (siehe Kreft
& Langer 2014 zum sogenannten Deadly Diamond of Death bei der Mehrfachvererbung).
Einen gewissen Ersatz bieten die im Kapitel 9 behandelten Schnittstellen (Interfaces), weil ...
• bei Schnittstellen die Mehrfachvererbung erlaubt ist,
• und außerdem eine Klasse mehrere Schnittstellen implementieren darf.

7.11.2 Finale Klassen


Gelegentlich gibt es Gründe dafür, eine Klasse mit dem Modifikator final zu deklarieren, sodass sie
zwar verwendet (z. B. instanziert), aber nicht beerbt werden kann. Bei der Klasse String im API-
Paket java.lang
public final class String { ... }
ist das Finalisieren z. B. erforderlich, damit keine abgeleitete Klasse die Unveränderlichkeit von
String-Objekten (vgl. Abschnitt 5.2.1) unterlaufen kann.
Abschnitt 7.11 Erzwungene und optionale Einschränkungen beim Vererben 413

7.11.3 Versiegelte Klassen


Mit Java 17 wurden versiegelte (sealed) Klassen eingeführt. Anders als bei finalen Klassen wird
hier die Definition von abgeleiteten Klassen nicht verhindert, sondern auf eine Liste von namentlich
bekannten Klassen beschränkt. Damit wird die Möglichkeit geschaffen, ....
• die Hierarchie von Objektsorten im Aufgabenbereich durch eine Klassenhierarchie zu model-
lieren,
• die Definition von weiteren abgeleiteten Klassen aber explizit zu verhindern, um die Mög-
lichkeiten des Compilers zur Erkennung von Fehlern zu verbessern.
Wir betrachten als Beispiel eine erweiterte Variante unserer Figurenhierarchie und definieren die
versiegelte (und abstrakte) Basisklasse Figur:
package de.uni_trier.zimk.figuren;

abstract public sealed class Figur permits Kreis, Rechteck, Quadrat {


protected double xpos = 100.0, ypos = 100.0;
. . .
}
Nach dem Schlüsselwort permits werden drei zulässige unmittelbare Spezialisierungen aufgelistet.
Wenn sich alle Klassen in derselben Quellcodedatei befinden, dann kann in der Definition der ver-
siegelten Basisklasse die permits-Klausel entfallen.
Von den zugelassenen Spezialisierungen sind die folgenden Bedingungen einzuhalten:1
• Bei der Übersetzung einer versiegelten Basisklasse muss der Compiler Zugang zu den Quell-
codedateien von allen (direkt oder indirekt) abgeleiteten Klassen haben.2
• In der Definition einer zugelassenen Spezialisierung muss einer von den folgenden Modifika-
toren angegeben werden:
o final
Zu dieser Klasse darf keine Ableitung definiert werden (siehe Abschnitt 7.11.2),
z. B.:
public final class Kreis extends Figur {
protected double radius = 50.0;
. . .
}
o sealed
Für diese Klasse werden die zugelassenen Spezialsierungen explizit aufgelistet, z. B.:
public sealed class Rechteck extends Figur permits RechteckZ {
protected double breite = 50.0, hoehe = 50.0;
. . .
}
o non-sealed
Zu dieser Klasse dürfen ohne Einschränkung abgeleitete Klassen definiert werden,
z. B.:
public non-sealed class Quadrat extends Figur {
protected double seite = 50.0;
. . .
}

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

7.12 Übungsaufgaben zum Kapitel 7


1) Warum kann der folgende Quellcode (mit zwei Klassen im Standardpaket) nicht übersetzt wer-
den?
// Datei General.java
class General {
int ig;
General(int igp) {
ig = igp;
}
void hallo() {
System.out.println("hallo-Methode der Klasse General");
}
}
// Datei Spezial.java
class Spezial extends General {
int is = 3;
void hallo() {
System.out.println("hallo-Methode der Klasse Spezial");
}
}

2) Im folgenden Beispiel wird die Klasse Kreis aus der Klasse Figur abgeleitet:
// Datei Figur.java
package de.uni_trier.zimk.figuren;

public class Figur {


double xpos, ypos;
}

// Datei Kreis.java
package de.uni_trier.zimk.figuren.kreis;

import de.uni_trier.zimk.figuren.Figur;

class Kreis extends Figur {


double radius;
Kreis(double x, double y, double rad) {
xpos = x;
ypos = y;
radius = rad;
}
}
Trotzdem erlaubt der Compiler dem Kreis-Konstruktor keinen Zugriff auf die geerbten Instanzva-
riablen xpos und ypos eines neuen Kreis-Objekts:
416 Kapitel 7 Vererbung und Polymorphie

Wie ist das Problem zu erklären und zu lösen?

3) Welche der folgenden Aussagen sind richtig bzw. falsch?


1. Aus einer abstrakten Klasse lassen sich keine Objekte erzeugen.
2. Aus einer abstrakten Klasse lassen sich keine Klassen ableiten.
3. In einer abstrakten Klasse müssen alle Methoden abstrakt sein.
4. Wird eine abstrakte Basisklasse beerbt, dann muss die abgeleitete Klasse alle abstrakten Me-
thoden implementieren.
5. Für ein per Basisklassenreferenz ansprechbares Objekt kann zur Laufzeit über den
instanceof - Operator festgestellt werden, ob es zu einer bestimmten abgeleiteten Klasse ge-
hört.

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).

8.1 Generische Klassen


Aus der Entwicklerperspektive besteht der wesentliche Vorteil einer generischen Klasse darin, dass
mit einer Definition beliebig viele konkrete Klassen zur Verwendung mit speziellen Datentypen
geschaffen werden. Dieses Konstruktionsprinzip ist speziell bei den Kollektionsklassen sehr ver-
breitet (siehe Kapitel 10), aber keinesfalls auf Kollektionen mit ihrer weitgehend inhaltstypunab-
hängigen Verwaltungslogik beschränkt.

8.1.1 Vorzüge und Verwendung generischer Klassen

8.1.1.1 Veraltete Technik mit Risiken und Umständlichkeiten


Im Abschnitt 5.3.2 haben Sie die Klasse ArrayList aus dem Paket java.util als Container für Ob-
jekte beliebigen Typs kennengelernt, z. B.:
java.util.ArrayList al = new java.util.ArrayList();
al.add("Otto");
al.add(13);
al.add(23.77);
al.add('x');

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).

8.1.1.2 Generische Klassen bringen Typsicherheit und Bequemlichkeit


Es wäre nicht schwer, eine spezielle Container-Klasse zur Verwaltung von String-Objekten zu de-
finieren, die die im letzten Abschnitt beschriebenen Probleme (mangelnde Typsicherheit, syntakti-
sche Umständlichkeit) vermeidet. Analog funktionierende Behälter werden aber auch für andere
Elementtypen benötigt, und entsprechend viele Klassen zu definieren, die sich nur durch den In-
haltstyp unterscheiden, wäre nicht rationell. Für eine solche Aufgabenstellung bietet Java seit der
Version 5 die generischen Klassen. Durch die Verwendung von Typformalparametern bei der Defi-
nition wird die gesamte Handlungskompetenz einer Klasse typunabhängig formuliert. Beim Instan-
ziieren (z. B. beim Erstellen von Container-Objekten) sind jedoch konkrete Typen anzugeben. Weil
der Compiler die konkreten Typen kennt, kann er bei Zuweisungen und Methodenaufrufen für Typ-
sicherheit sorgen. Im Quellcode sind keine lästigen Typumwandlungen erforderlich; die fügt der
Compiler automatisch in den Bytecode ein.
Wie ein Blick in die API-Dokumentation zeigt, ist die Klasse ArrayList<E> selbstverständlich
generisch definiert und verwendet den Typformalparameter E:1
Module java.base
Package java.util
Class ArrayList<E>
java.lang.Object
java.util.AbstractCollection<E>
java.util.AbstractList<E>
java.util.ArrayList<E>
Type Parameters:
E - the type of elements in this list
Der im Abschnitt 8.1.1.1 verwendetet Rohtyp der generischen Klasse ArrayList<E> ist wenig ge-
eignet, wenn ein sortenreiner Container (mit identischem Typ für alle Elemente) benötigt wird. Die
beiden Nachteile dieser Konstellation (Typunsicherheit und lästige Typumwandlungen) wurden
oben beschrieben.
Wird ein ArrayList<String> - Objekt mit Angabe des gewünschten Elementtyps (Typaktualpara-
meters) String verwendet,
import java.util.ArrayList;
class GenArrayList {
public static void main(String[] args) {
ArrayList<String> al = new ArrayList<String>();
al.add("Otto");
al.add("Rempremerding");
// al.add('.'); // führt zum Übersetzungsfehler
int i = 0;
for (String s: al)
System.out.printf("Länge von String %d: %d\n", ++i, s.length());
}
}
dann verhindert der Compiler die Aufnahme eines Elements mit unpassendem Datentyp:

1
https://docs.oracle.com/en/java/javase/17/docs/api/java.base/java/util/ArrayList.html
420 Kapitel 8 Generische Klassen und Methoden

Außerdem beherrschen die String-Elemente des ArrayList<String> - Containers ohne Typum-


wandlung die Methoden ihrer Klasse, z. B.:
s.length()
Durch die Verwendung generischer Klassen sind Programme ...
• robuster aufgrund der Typüberwachung durch den Compiler,
• leichter zu erstellen, weil die Typumwandlungen entfallen,
• leichter zu lesen.
Bei der Verwendung eines generischen Typs durch Wahl konkreter Datentypen an Stelle der
Typformalparameter entsteht ein sogenannter parametrisierter Typ, z. B. ArrayList<String>.1
Als Konkretisierung für einen Typformalparameter ist ein Referenzdatentyp vorgeschrieben. Den
Grund für diese Einschränkung erfahren Sie im Abschnitt 8.1.2.1. Zwar werden über Verpackungs-
klassen und Auto(un)boxing auch primitive Typen unterstützt, doch ist bei einer sehr hohen Anzahl
von Auto(un)boxing-Operationen mit einem relativ hohen Zeitaufwand zu rechnen.
Seit Java 7 ist es beim Instanziieren parametrisierter Typen nicht mehr erforderlich, den Typaktual-
parameter in der Bezeichnung des Konstruktors zu wiederholen, sodass man bei der Deklaration mit
Initialisierung
ArrayList<String> als = new ArrayList<String>();
etwas Schreibaufwand sparen kann:
ArrayList<String> als = new ArrayList<>();
Aus dem deklarierten Datentyp lässt sich der Typaktualparameter sicher ableiten, und seit Java 7
können schreibfaule Programmierer von dieser Typinferenz profitieren.
Manche Autoren bezeichnen das Paar unmittelbar aufeinander folgender spitzer Klammern in laxer
Redeweise als diamond operator, obwohl es sich nicht um einen Operator handelt.

8.1.2 Technische Details und Komplikationen

8.1.2.1 Typlöschung und Rohtyp


Java-Compiler erzeugen für eine generische Klasse unabhängig von der Anzahl der im Quellcode
vorhandenen Konkretisierungen (parametrisierten Typen) ausschließlich den sogenannten Rohtyp.
Hier sind die Typformalparameter jeweils durch den generellsten zulässigen Datentyp ersetzt. Bei
unrestringierten Parametern ist diese obere Begrenzung (engl.: upper bound) der Urahntyp Object,
bei restringierten Parametern ist sie entsprechend enger (siehe Abschnitt 8.1.3.2). Man spricht hier
von Typlöschung (engl.: type erasure). Im Bytecode existieren also keine parametrisierten Typen,
sondern nur der Rohtyp.

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:

Warum der parametrisierte Typ ArrayList<String> keine Spezialisierung des parametrisierten


Typs ArrayList<Object> ist, wird gleich im Abschnitt 8.1.2.2 erläutert.
Weil man es nicht oft genug sagen kann, steht am Ende dieses Abschnitts noch einmal in den Wor-
ten von Joshua Bloch (2018, S. 117) der dringende Rat:
Don’t use raw types.

8.1.2.2 Spezialisierungsbeziehungen bei parametrisierten Klassen und bei Arrays


Bei der ersten Beschäftigung mit generischen Klassen könnte man z. B. den parametrisierten Typ
ArrayList<String>
für eine Spezialisierung des parametrisierten Typs
ArrayList<Object>
Abschnitt 8.1 Generische Klassen 423

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

ablehnt, erlaubt er das analoge Vorgehen bei einem Array:


Object[] arrObject = new String[5]; // leider erlaubt
Für die beiden gravierend abweichenden Regeln bei der Übertragung der Spezialisierungsrelation
von der Element- auf die Container-Ebene haben sich die folgenden Begriffe eingebürgert:
• Generischen Typen sind invariant.
• Arrays sind kovariant.
Warum der statistische Terminus Kovarianz hier zum Einsatz kommt, wurde im Abschnitt
5.1.3 veranschaulicht.
Aufgrund der Kovarianz-Eigenschaft von Arrays übersetzt der Compiler z. B. die folgenden Anwei-
sungen ohne jede Kritik:
Object[] arrObject = new String[5];
arrObject[0] = 13;
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:6)
Der per
new String[5]
erzeugte Array kennt zur Laufzeit seinen Elementtyp (String) und lehnt die Aufnahme eines Inte-
ger-Objekts ab. Weil Programmierfehler nicht zur Laufzeit (eventuell vom Kunden), sondern vom
Compiler entdeckt werden sollten, ist die bei Arrays realisierte kovariante Zuweisungskompatibili-
tät als Mangel einzuschätzen, von dem neben Java auch andere Programmiersprachen betroffen sind
(z. B. C#).
424 Kapitel 8 Generische Klassen und Methoden

8.1.2.3 Keine Array-Kreation mit einem nicht-reifizierbaren Elementtyp


Wegen der Typlöschung bei generischen Klassen und der Kovarianz von Arrays passen in Java die
Generizität und Arrays schlecht zusammen (Bloch 2018, S. 126ff). Insbesondere lässt sich kein Ar-
ray mit einer konkretisierten Variante einer generischen Klasse als Elementtyp erstellen, z. B.:

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.

8.1.2.4 Serienparameter mit einem parametrisierten Typ


Mit Hilfe eines Serienparameters (siehe Abschnitt 4.3.1.4.3) gelingt es, einen Array mit einem pa-
rametrisierten Elementtyp anzulegen. Im folgenden Beispiel, das im Wesentlichen aus der Java-API
- Online-Dokumentation1 stammt, verwendet die Methode genVarargs() einen Serienparameter
vom Typ ArrayList<String>, also letztlich einen Parameter vom Typ ArrayList<String>[], der
im Abschnitt 8.1.2.3 als unmöglich dargestellt wurde:
import java.util.ArrayList;
class Prog {
private void genVarargs(ArrayList<String>... stringLists) {
ArrayList[] array = stringLists;
ArrayList<Integer> iList = new ArrayList<>();
iList.add(13);
array[0] = iList;
String s = stringLists[0].get(0);
}
public static void main(String[] args) {
Prog p = new Prog();
p.genVarargs(new ArrayList<String>());
}
}
In der Methode main() wird beim Aufruf an genVarargs() ein Element mit dem vereinbarten
parametrisierten Typ übergeben. In Methodenrumpf wird der Array mit ArrayList<String> - Ele-
menten über eine ArrayList[] - Referenz dazu gebracht, ein Element vom Typ Array-
List<Integer> aufzunehmen, das den passenden Rohtyp ArrayList besitzt. Das in die Array-

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

8.1.3 Definition von generischen Klassen


Wie Sie im Abschnitt 8.1.1.2 feststellen konnten, ist die Verwendung von generischen Typen aus
der Standardbibliothek sehr empfehlenswert und einfach handhabbar. Aber auch die Definition von
eigenen generischen Klassen ist kein großes Problem, wenngleich man dabei mit einigen techni-
schen Details und Komplikationen der in Java realisierten Generizitätslösung in näheren Kontakt
kommt. Auch Record-Klassen (siehe Abschnitt 5.5) können generisch definiert werden.1
Im Namen der Datei mit dem Quellcode einer generischen Klasse tauchen keine Typformalparame-
ter auf. Z. B. steckt die Klasse ArrayList<E> der Java-Standardbibliothek in einer Datei mit dem
Namen ArrayList.java.

8.1.3.1 Unbeschränkte Typformalparameter


Bei der generischen Klassendefinition verwendet man Typformalparameter, die im Definitions-
kopf hinter dem Klassennamen zwischen spitzen Klammern angegeben werden. Verwendet eine
Klassendefinition mehrere Typformalparameter, dann sind diese durch Kommata voneinander zu
trennen. Wir erstellen nun als einfaches Beispiel eine generische Klasse namens SimpleList<E>,
die hinsichtlich Einsatzzweck und Konstruktion den Listenverwaltungsklassen aus dem Java Coll-
ections Framework ähnelt (z. B. ArrayList<E>, siehe oben und Abschnitt 10.4), aber nicht annä-
hernd denselben Funktionsumfang bietet:
package de.uni_trier.zimk.util.coll;
import java.util.Arrays;

public class SimpleList<E> {


private Object[] elements;
private final static int DEF_INIT_SIZE = 16;
private int initSize;
private int size;

public SimpleList(int len) {


if (len > 0) {
initSize = len;
elements = new Object[len];
} else {
initSize = DEF_INIT_SIZE;
elements = new Object[DEF_INIT_SIZE];
}
}

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;
}

public E get(int index) {


if (index >= 0 && index < size) {
return (E) elements[index];
} else
return null;
}

1
Hier ist ein Beispiel zu finden: https://jaxenter.com/java-14-records-deep-dive-169879.html
428 Kapitel 8 Generische Klassen und Methoden

public int size() {return size;}

public int capacity() {return elements.length;}


}
Innerhalb der Klassendefinition kann der Typformalparameter E in vielen Situationen wie ein kon-
kreter Referenzdatentyp verwendet werden (als Typ von Instanzvariablen, lokalen Variablen, Me-
thodenparametern und Rückgabewerten). Wie gleich zu sehen ist, bestehen allerdings Einschrän-
kungen bei der Verwendung eines Typformalparameters.
Es wird empfohlen, für Typformalparameter einzelne Großbuchstaben zu verwenden (Bloch 2018,
S. 290):
T Type
Treten mehrere Typen auf, können diese mit T, U, V, ... oder mit T1, T2, T3, ... benannt
werden.
E Element
R Return Type
K Key
V Value
X Exception
In den Namen der Konstruktoren einer generischen Klasse werden die Typformalparameter nicht
wiederholt, z. B.:
public class SimpleList<E> {
. . .
public SimpleList() {
initSize = DEF_INIT_SIZE;
elements = new Object[DEF_INIT_SIZE];
}
. . .
}

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;

public class SimpleList {


private Object[] elements;
private static final int DEF_INIT_SIZE = 16;
private int initSize;
private int size;

// Konstruktoren wie beim generischen Typ

public void add(Object element) {


if (size == elements.length)
elements = Arrays.copyOf(elements, elements.length + initSize);
elements[size++] = element;
}

public Object get(int index) {


if (index >= 0 && index < size)
return elements[index];
else
return null;
}

public int size() {return size;}

public int capacity() {return elements.length;}


}

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;
}

public static void main(String[] egal) {


Prog<Void> pv = new Prog<Void>();
Void v = pv.givePar(null);
System.out.println(v);
}
}

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

8.1.3.2 Beschränkte Typformalparameter


Häufig muss eine generische Klasse oder Methode (siehe Abschnitt 8.2) bei den Klassen, welche
einen Typparameter konkretisieren dürfen, gewisse Handlungskompetenzen voraussetzen. Soll z. B.
ein generischer Container mit dem Typformalparameter E seine Elemente sortieren, dann muss für
jede konkrete Elementklasse gefordert werden, dass sie das Interface Comparable<E> implemen-
tiert. Wir begegnen hier erneut dem wichtigen Begriff Interface, mit dem wir uns im Kapitel 9 aus-
führlich beschäftigen werden. Allerdings stellt der Vorgriff kein didaktisches Problem dar, weil die
Forderung an eine zulässige Konkretisierungsklasse leicht mit vertrauten Begriffen zu formulieren
ist: Es muss eine Instanzmethode namens compareTo() mit dem folgenden Definitionskopf vor-
handen sein (hier beschrieben unter Verwendung des Typformalparameters E):
public int compareTo(E vergl)
Das angesprochene Objekt vergleicht sich mit dem Parameterobjekt vom selben Typ.
Im Abschnitt 5.2.1.4.2 haben Sie erfahren, dass die Klasse String eine solche Methode besitzt, und
wie compareTo() das Prüfergebnis über den Rückgabewert signalisiert. Damit sollte klar sein, was
die Schnittstelle (das Interface) Comparable<E> von einer implementierenden Klasse verlangt:
Objekte dieser Klasse müssen sich mit Artgenossen vergleichen können. Wie das Beispiel Compa-
rable<E> zeigt, sind auch bei Schnittstellen typgenerische Varianten von großer Bedeutung.
Wir erstellen nun eine Variante der simplen Listenklasse aus dem Abschnitt 8.1.2.1, die neue Ele-
mente automatisch einsortiert und daher ihren Typformalparameter auf den Datentyp Comparab-
le<E> einschränkt:1
package de.uni_trier.zimk.util.coll;
import java.util.Arrays;

public class SimpleSortedList<E extends Comparable<E>> {


private E[] elements;
private final static int DEF_INIT_SIZE = 16;
private int initSize;
private int size;

@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

public void add(E element) {


if (size == elements.length)
elements = Arrays.copyOf(elements, elements.length + initSize);
int i;
for (i = size-1; i >= 0 && element.compareTo(elements[i]) < 0; i--)
elements[i+1] = elements[i];
elements[i+1] = element;
size++;
}

public E get(int index) {


if (index >= 0 && index < size)
return elements[index];
else
return null;
}

public int size() {return size;}

public int capacity() {return elements.length;}


}
Bei der Formulierung von Beschränkungen für Typformalparameter wird das Schlüsselwort ex-
tends verwendet, das wir im Zusammenhang mit Vererbungsbeziehungen zwischen Klassen ken-
nengelernt haben. Zum Zweck der Typrestriktion kann hinter dem Schlüsselwort eine Basisklasse
oder (wie im Beispiel) eine zu implementierende Schnittstelle angegeben werden. Bei der Restrikti-
on von Typformalparametern wird das Schlüsselwort extends also auch für Schnittstellen verwen-
det, während in der Definition einer Klasse, die sich zu einer Schnittstelle bekennt, das Schlüssel-
wort implements vorgeschrieben ist (siehe Kapitel 9).
Wenn (wie im Beispiel) der Typformalparameter E in der Beschränkungsdefinition selbst auftaucht,
E extends Comparable<E>
spricht Bloch (2018, S. 137) von einer rekursiven Typeinschränkung.
Wie Sie bereits wissen, kann der intern zur Datenspeicherung verwendete Array nicht mit dem
Elementtyp E erzeugt werden. Zur Lösung des Problems verwaltet man die Elemente durch ein Ar-
ray-Objekt mit dem generellsten zulässigen Elementtyp. Im aktuellen Beispiel der generischen
Klasse SimpleSortedList<E extends Comparable<E>> ist dies der Interface-Datentyp
Comparable. Im folgenden Ausdruck
new Comparable[DEF_INIT_SIZE]
wird ein Array-Objekt erzeugt, das Elemente aus jeder beliebigen Klasse aufnehmen kann, die das
Interface Comparable erfüllt.
Für den restlichen Lösungsweg gibt es zwei im Wesentlichen äquivalente Techniken:
434 Kapitel 8 Generische Klassen und Methoden

• Instanzvariable vom Typ Comparable[] deklarieren und in Methoden eine Typwandlung in


Richtung Typformalparameter vornehmen, z. B.:
public class SimpleSortedList<E extends Comparable<E>> {
private Comparable[] elements;
. . .
public SimpleSortedList() {
initSize = DEF_INIT_SIZE;
elements = new Comparable[DEF_INIT_SIZE];
}

public void add(E element) {


if (size == elements.length)
elements = Arrays.copyOf(elements, elements.length + initSize);
int i;
for (i = size-1; i >= 0 && element.compareTo((E) elements[i]) < 0; i--)
elements[i+1] = elements[i];
elements[i+1] = element;
size++;
}

public E get(int index) {


if (index >= 0 && index < size)
return (E) elements[index];
else
return null;
}
}
• Bei der Instanzvariablen für den internen Array den Typ des Formalparameters verwenden
und auf den Comparable-Array eine Typwandlung anwenden, z. B.:
public class SimpleSortedList<E extends Comparable<E>> {
private E[] elements;
. . .
public SimpleSortedList() {
initSize = DEF_INIT_SIZE;
elements = (E[]) new Comparable[DEF_INIT_SIZE];
}

public void add(E element) {


if (size == elements.length)
elements = Arrays.copyOf(elements, elements.length + initSize);
int i;
for (i = size-1; i >= 0 && element.compareTo(elements[i]) < 0; i--)
elements[i+1] = elements[i];
elements[i+1] = element;
size++;
}

public E get(int index) {


if (index >= 0 && index < size)
return elements[index];
else
return null;
}
}

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));
}
}

Der Compiler stellt sicher, dass die Liste sortenrein bleibt:

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).

8.2 Generische Methoden


Im Vergleich zu mehreren überladenen Methoden (vgl. Abschnitt 4.3.4), die analoge Operationen
mit verschiedenen Datentypen ausführen, ist eine generische Methode oft die bessere Lösung. Im
folgenden Beispiel liefert eine statische und generische Methode das Maximum von zwei Argumen-
ten, wobei der gemeinsame Datentyp der Argumente die Schnittstelle Comparable<T> (vgl. Ab-
schnitt 8.1.3.2) erfüllen, also eine Methode compareTo(T vergl) besitzen muss:
436 Kapitel 8 Generische Klassen und Methoden

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;
}

public static void main(String[] args) {


System.out.println("String-max:\t" + max("abc", "def"));
System.out.println("int-max:\t" + max(12, 4711));
}
}

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;
}

static int max(int x, int y) {


return x > y ? x : y;
}

public static void main(String[] args) {


System.out.println("String-max:\t" + max("abc", "def"));
System.out.println("int-max:\t" + max(12, 4711));
}
}

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;

static <T extends Comparable<T>> T max(T x, T y) {


return x.compareTo(y) > 0 ? x : y;
}

static int imax(int x, int y) {


return x > y ? x : y;
}

public static void main(String[] args) {


long start = System.currentTimeMillis();
for (int i = 0; i < VERGL; i++)
max(12, 4711);
System.out.println("Zeit für " + VERGL + " Aufrufe der generischen Methode: \t" +
(System.currentTimeMillis() - start));

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.

8.3.1 Beschränkte Wildcard-Typen

8.3.1.1 Beschränkung nach oben


Unsere generische Beispielklasse SimpleList<E> aus dem Abschnitt 8.1.2.1 soll um eine Metho-
de addList() erweitert werden, sodass die angesprochene Liste alle Elemente einer zweiten, typ-
kompatiblen Liste übernehmen kann. Wir starten mit der folgenden Definition, die sich bald als
unpraktisch einschränkend herausstellen wird:
public void addList(SimpleList<E> list) {
if (size + list.size > elements.length)
elements = Arrays.copyOf(elements, size + list.size + initSize);
for (int i = 0; i < list.size; i++)
elements[size++] = list.get(i);
}
In einem Testprogramm erzeugen wir ein Listenobjekt mit dem parametrisierten Typ
SimpleList<Number>:
SimpleList<Number> sln = new SimpleList<>(3);
Bei der Einzelelementaufnahme über die Methode
public void add(E element) {
. . .
}
sind Objekte einer beliebigen Ableitung der abstrakten Klasse Number erlaubt, z. B.:
sln.add(13); sln.add(1.13);
Demgegenüber scheitert der Versuch, über die eben definierte Methode addList() alle Elemente
eines SimpleList<Integer> - Objekts aufzunehmen:
440 Kapitel 8 Generische Klassen und Methoden

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());
}
}

8.3.1.2 Beschränkung nach unten


Neben der eben vorgestellten Beschränkung nach oben über das Schlüsselwort extends erlaubt Java
auch die Beschränkung nach unten über das Schlüsselwort super, wobei zur Typparameter-
Konkretisierung eine bestimmte Klasse und ihre sämtlichen (auf verschiedene Ebenen angesiedel-
ten) Basisklassen bis hinauf zur Urahnklasse Object zugelassen sind (engl.: lower bound). Zur Il-
lustration der Beschränkung nach oben erweitern wir die generische Klasse SimpleList<E> um
eine Methode namens copyElements(), die die angesprochene Liste auffordert, ihre Elemente in
eine per Parameter benannte Liste zu kopieren. Die folgende Definition
Abschnitt 8.3 Wildcard-Datentypen 441

public void copyElements(SimpleList<E> list) {


for (int i = 0; i < size; i++)
list.add((E) elements[i]);
}
ist wenig nützlich, weil die Abnehmerliste exakt vom selben Typ wie die Lieferantenliste sein muss.
Es kann z. B. aber durchaus sinnvoll sein, die Elemente eines SimpleList<Integer> - Objekts
in ein SimpleList<Number> - Objekt zu kopieren. Selbst der Abnehmertyp SimpleList<Ob-
ject> kommt in Frage. So sieht die sinnvolle Implementierung der Methode copyElements()
aus:
public void copyElements(SimpleList<? super E> list) {
for (int i = 0; i < size; i++)
list.add((E) elements[i]);
}

Nach einer von Bloch (2018, S. 141) angegebenen Merkregel ...


• ist ein Wildcard-Typ mit extends - Restriktion für Eingabeparameter zu verwenden, die ei-
nen Produzenten repräsentieren,
• ist ein Wildcard-Typ mit super - Restriktion für Ausgabeparameter zu verwenden, die einen
Konsumenten repräsentieren,
• ist ein einfacher Typformalparameter (ohne Wildcard) zu verwenden, wenn das durch einen
Parameter repräsentierte Objekt sowohl Produzent als auch Konsument sein kann.

8.3.2 Unbeschränkte Wildcard-Typen


Um bei einer Variablen- oder Parameterdeklaration unter Verwendung einer generischen Klasse für
einen Typformalparameter beliebige Konkretisierungen zu erlauben, verwendet man den ungebun-
denen Wildcard-Typ. Als Beispiel betrachten wird die statische Methode shuffle() der API-Klasse
Collections im Paket java.util (siehe Abschnitt 10.10), die die Elemente der Aktualparameterliste
in eine zufällige Reihenfolge bringt:
public static void shuffle(List<?> list) {
. . .
}
Als Datentyp für den Aktualparameter ist jede Konkretisierung von List<E> erlaubt, z. B.
List<String>, List<Object>, usw. Weil der Compiler über den Typ der Elemente nichts weiß,
kann man über den Parameter mit dem unbeschränkten Wildcard-Typ List<?> nicht allzu viel an-
stellen und insbesondere keine Elemente (außer null) in die Liste einfügen.

8.3.3 Verwendungszwecke für Wildcard-Datentypen


Bisher sind uns (beschränkte) Wildcard-Datentypen als Methodenformalparameter begegnet, und in
dieser Rolle werden sie auch am häufigsten benötigt. Sie bewähren sich aber auch bei der Restrikti-
on von Typformalparametern. In der API-Klasse Collections aus dem Paket java.util (siehe Ab-
schnitt 10.10) findet sich z. B. die generische und statische Methode sort(), die eine Liste mit Ele-
menten vom Typ T sortiert:
public static <T extends Comparable<? super T>> void sort(List<T> list)
Mit der Restriktion (T extends Comparable<? super T>) wird vom Typ T eine Methode
compareTo() verlangt, wobei T selbst oder eine Basisklasse von T als Parametertyp erlaubt sind.
Damit ist insgesamt als T-Konkretisierung auch eine Klasse möglich, die die Methode
compareTo() nicht selbst implementiert, sondern von einer Basisklasse erbt. Schließlich ist die
Vergleichbarkeit mit Artgenossen auf diese Weise sichergestellt.
442 Kapitel 8 Generische Klassen und Methoden

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).

8.4 Einschränkungen der Generizitätslösung in Java


Generische Klassen, Schnittstellen und Methoden sind für die professionelle Software-Entwicklung
in Java unverzichtbare Bestandteile, doch enthält die Java-Generizitätslösung (hauptsächlich be-
dingt durch das Bemühen um Kompatibilität mit älteren Lösungen) einige Einschränkungen, die
abschließend noch einmal angesprochen werden sollen.

8.4.1 Konkretisierung von Typformalparametern nur durch Referenztypen


Als Konkretisierung für einen Typformalparameter kommt nur ein Referenztyp in Frage. Dank
Wrapper-Klassen und Auto(un)boxing lassen sich zwar auch primitive Werte ohne großen syntakti-
schen Aufwand versorgen, doch ist bei einer großen Zahl von Auto(un)boxing-Operationen mit
einem relativ hohen Zeitaufwand zu rechnen. Sollen z. B. ganze Zahlen in einem Objekt der Klasse
SimpleList<E> (vgl. Abschnitt 8.1.3.1) abgelegt werden, scheidet der folgenden Ansatz aus:
SimpleList<int> si = new SimpleList<>();
Als Ersatzlösung ist zu verwenden:
SimpleList<Integer> si = new SimpleList<>();
Im Abschnitt 8.2 wurde durch ein Beispielprogramm demonstriert, welchen zeitlichen Mehrauf-
wand die Unterstützung primitiver Aktualparameter durch eine generische Methode im Vergleich
zu einer für den primitiven Typ speziell erstellten Methodenüberladung zur Folge hat. Um ca. 20
Millisekunden bei der Unterstützung des Parametertyps int durch eine generische Methode zu ver-
geuden, waren allerdings 1 Million Methodenaufrufe erforderlich. Es lohnt sich also nur in Aus-
nahmefällen, spezifische Lösungen für primitive Typen (ergänzend zu einer generischen Lösung) zu
erstellen, um die Performanz einer Anwendung zu verbessern.

8.4.2 Typlöschung und die Folgen


Eine generische Klasse ist unabhängig von der Anzahl der im Quellcode vorhandenen Konkretisie-
rungen (parametrisierten Typen) im Bytecode nur durch ihren Rohtyp vertreten.

8.4.2.1 Keine Typparameter bei der Definition von statischen Mitgliedern


Weil alle parametrisierten Typen dieselben statischen Variablen und Methoden der Klasse verwen-
den, darf bei der Deklaration von statischen Feldern und bei der Definition von statischen Metho-
den kein Typparameter verwendet werden, z. B.:

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

einer unchecked-Warnung darauf aufmerksam, dass er keine Kontrollmöglichkeit hat. Im Beispiel


SimpleList<E> haben wir die Typsicherheit durch Sorgfalt beim Klassendesign hergestellt und
die somit irrelevante Warnung unterdrückt (siehe Abschnitt 8.1.3.1).
Die bei SimpleList<E> benutzte Lösung wird übrigens auch bei der API-Klasse ArrayList<E>
verwendet, z. B.:1
public class ArrayList<E> extends AbstractList<E>
implements List<E>, RandomAccess, Cloneable, java.io.Serializable {

transient Object[] elementData;


private int size;
. . .
@SuppressWarnings("unchecked")
E elementData(int index) {
return (E) elementData[index];
}

public E get(int index) {


Objects.checkIndex(index, size);
return elementData(index);
}
. . .
}
In der Methode elementData(), die denselben Namen trägt wie der intern zum Speichern der
Elemente verwendete Object[] - Array, findet eine explizite Typwandlung statt, und die Compiler-
Warnung wird per Annotation unterdrückt.
Die aus Kompatibilitätsgründen gewählte Typlöschung ist als Schwachstelle bei der Generizitätslö-
sung in Java zu kritisieren. Bei der Verwendung generischer Klassen überwacht der Java-Compiler
die Typsicherheit. Beim Klassendesign ist der Programmierer für die Typsicherheit verantwortlich.

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;

public class Genni<E> {


private ArrayList<E> ale = new ArrayList<E>();
public void add(E e) {
ale.add(e);
}

public ArrayList<E> get() {


return ale;
}
}
Im folgenden Testprogramm wird ein Objekt vom parametrisierten Typ Genni<String> erstellt:
Quellcode Ausgabe
public class GenniTest { [Otto, Rempremerding]
public static void main(String[] args) {
Genni<String> gs = new Genni<>();
gs.add("Otto"); gs.add("Rempremerding");
// gs.add(13); // wird vom Compiler abgelehnt
System.out.println(gs.get());
}
}

Der Compiler kann die Typsicherheit garantieren, z. B.:

8.5 Übungsaufgaben zum Kapitel 8


1) In der folgenden Klassendefinition ist eine statische Methode namens printAll() vorhanden,
die für Listen mit beliebigem festen Elementtyp alle Elemente ausgibt und dazu einen Formalpara-
meter mit Wildcard-Datentyp verwendet:
Quellcode Ausgabe
import java.util.*; Otto
class Prog { Ludwig
static void printAll(List<?> list) { Karl
for (Object e : list)
System.out.println(e);
}

public static void main(String[] args) {


ArrayList<String> als = new ArrayList<>();
als.add("Otto"); als.add("Ludwig"); als.add("Karl");
printAll(als);
}
}

Erstellen Sie eine funktionsgleiche generische Methode mit einem Typformalparameter.


446 Kapitel 8 Generische Klassen und Methoden

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);
}

public static void main(String[] args) {


ArrayList<String> sl = new ArrayList<>();
addElement(sl, 42);
System.out.println(sl.get(0));
}
}
Zur Laufzeit scheitert es mit der Meldung:
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:10)
Wie ist der Fehler zu erklären?

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.

9.1.2 Primäre Funktion


Ein Interface (dt.: eine Schnittstelle) dient in der Regel dazu, Verhaltenskompetenzen von Objekten
einer implementierenden Klasse über eine Liste von abstrakten Instanzmethoden zu definieren. Seit
Java 8 können Interface-Designer zu einer Instanzmethode aber auch eine default-Implementierung
liefern. Wenn sich eine Klasse zu einem Interface bekennt, gibt sie eine Verpflichtungserklärung
ab und muss alle im Interface beschriebenen Instanzmethoden implementieren, falls kein glückli-
cher Umstand die eigene Methodendefinition erübrigt:

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

9.1.3 Mögliche Bestandteile


Ein Interface kann folgende Bestandteile (Mitglieder) enthalten:
• Instanzmethoden
Öffentliche Instanzmethoden können abstrakt sein (vgl. Abschnitt 9.2.3.1), oder eine
default-Implementierung besitzen (vgl. Abschnitt 9.2.3.2). Die seit Java 9 erlaubten priva-
ten Instanzmethoden müssen eine Implementierung besitzen (vgl. Abschnitt 9.2.3.4).
• Statische Methoden (vgl. Abschnitt 9.2.3.3)
Seit Java 8 sind in einem Interface auch statische Methoden erlaubt. Im Unterschied zu den
Instanzmethoden einer Schnittstelle, die in der Regel abstrakt definiert sind, müssen die sta-
tischen Methoden in der Schnittstelle implementiert werden. Seit Java 9 können statische
Methoden als private deklariert werden.
• Konstanten (vgl. Abschnitt 9.2.4)
Manche Schnittstellen dienen auch (oder sogar ausschließlich) als Aufbewahrungsort für
Konstanten, die von verschiedenen Klassen genutzt werden sollen.
• Statische Mitgliedstypen (vgl. Abschnitt 9.2.5)
Die in einem Interface definierten Typen (Klassen, Enumerationen, Schnittstellen) sind im-
plizit als static deklariert (vgl. Abschnitt 4.8.1.2). Sie werden also wie Top-Level - Typen
behandelt, doch ist bei ihrer Verwendung durch fremde Typen ein Doppelname anzugeben,
z. B. WinterFace.SchneeEnum.PULVER bei dem im Interface WinterFace definierten
Aufzählungstyp SchneeEnum mit der Enumerationskonstanten PULVER.
Die Interface-Bestandteile sind implizit als public deklariert und können von jeder Klasse genutzt
werden, welche Zugriffsrechte für das Interface besitzt (per Voreinstellung von allen Klassen im
selben Paket). Der Modifikator public kann bei Interface-Bestandteilen weggelassen werden, ist
aber erlaubt. Seit Java 9 können Interface-Methoden als private definiert werden.

9.2 Interfaces definieren


Wir behandelt zuerst das im Programmieralltag vergleichsweise seltene Definieren einer Schnitt-
stelle, weil man dabei einen guten Eindruck von den Bestandteilen einer Schnittstelle gewinnt. Im
API-Paket java.lang befindet sich die angenehm einfach aufgebaute und außerordentlich wichtige
Schnittstelle Comparable<T>:1
package java.lang;
import java.util.*;

public interface Comparable<T> {


public int compareTo(T o);
}

Im Unterschied zu einer Klassendefinition steht im Kopf einer Schnittstellendefinition vor dem


Typnamen das Schlüsselwort interface. Seit Java 5 können dem Interface-Namen begrenzt durch
ein Paar spitzer Klammern Typformalparameter angehängt werden, sodass für konkrete Typen je-
weils eine eigene Interface-Definition entsteht (vgl. Kapitel 8 zu generischen Typen). Im Abschnitt
9.1.1 ist uns mit dem parametrisierten Interface Comparable<Double> schon eine Konkretisierung
der generischen Schnittstelle Comparable<T> begegnet. Im Abschnitt 10.7 über Kollektionen mit
(Schlüssel-Wert) - Elementen werden Sie das Interface Map<K,V> mit zwei Typformalparametern
(für Key und Value) kennenlernen.

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

Im Schnittstellenrumpf werden in der Regel abstrakte Instanzmethoden aufgeführt, deren Rumpf


durch ein Semikolon ersetzt ist. Dabei werden die Typformalparameter wie gewöhnliche Typbe-
zeichner verwendet. Auf diese Weise wird definiert, welche Methodenaufrufe Objekte einer imple-
mentierenden Klasse beherrschen müssen.
Meist beschreiben die Schnittstellendesigner in der begleitenden Dokumentation das erwünschte
Verhalten der Methoden. Z. B. wird in der Dokumentation zur Schnittstelle Comparable<T> die
Methode compareTo() so erläutert:1
Compares this object with the specified object for order. Returns a negative integer, zero, or a
positive integer as this object is less than, equal to, or greater than the specified object.
Der Compiler kann aber bei einer implementierenden Klasse nur die Einhaltung der syntaktischen
Regeln sicherstellen, sodass er z. B. auch die folgende compareTo() - Realisation einer Klasse
Joke akzeptiert:
class Joke implements Comparable<Joke> {
public int compareTo(Joke a) {
return 13;
}
}

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.1 Kopf einer Schnittstellen-Definition


Regeln für den Kopf einer Schnittstellen-Definition:
• Modifikator abstract
Weil Schnittstellen grundsätzlich abstract sind, muss der Modifikator nicht angegeben wer-
den. Er ist erlaubt, aber unüblich.
• Zugriffsmodifikatoren
Bei Top-Level - Schnittstellen ist nur der Modifikator public erlaubt. Ohne diesen Modifi-
kator ist die Schnittstelle nur innerhalb ihres Pakets verwendbar. Durch den Zugriffsmodifi-
kator public wird die Verwendung in berechtigten Paketen erlaubt (siehe Abschnitt 6.2 über
die mit Java 9 eingeführten Module). Ein Interface mit Schutzstufe public muss prinzipiell
in einer eigenen Datei (ohne weitere Top-Level - Typen mit public-Sichtbarkeit) definiert
werden.
Bei Mitgliedsschnittstellen sind dieselben Zugriffsmodifikatoren erlaubt wie anderen Mit-
gliedern.
Bei lokalen Schnittstellen sind keine Zugriffsmodifikatoren erlaubt.
• Modifikator static
Bei Mitgliedschnittstellen ist der Modifikator static erlaubt, aber überflüssig, weil diese
Schnittstellen grundsätzlich statisch sind.
• Schlüsselwort interface
Das obligatorische Schlüsselwort dient zur Unterscheidung zwischen Klassen- und Schnitt-
stellendefinitionen.
• Schnittstellenname
Wie bei Klassennamen sollte man den ersten Buchstaben groß schreiben. Seit Java 5 kann
man dem Interface-Namen zwischen spitzen Klammern einen oder mehrere (jeweils durch
ein Komma getrennte) Typformalparameter folgen lassen (analog zu den im Kapitel 8 be-
schriebenen generischen Klassen).

9.2.2 Vererbung bzw. Erweiterung bei Schnittstellen


Ein Interface kann ein anderes Interface beerben bzw. erweitern, wobei eine solche Generalisie-
rungsbeziehung wie bei Klassen unter Verwendung des Schlüsselworts extends deklariert wird,
z. B.:
interface SortedSet<E> extends Set<E> {
. . .
}
Während bei Java-Klassen die (z. B. von C++ bekannte) Mehrfachvererbung nicht unterstützt wird
(siehe Abschnitt 7.11.1), ist sie bei Java-Schnittstellen möglich (und oft auch sinnvoll), z. B.:
public interface Transform extends XMLStructure, AlgorithmMethod {
. . .
}
Eine mit der Urahnklasse Object vergleichbare Urahnschnittstelle gibt es in Java nicht.
Bei einer Schnittstelle mit (direkten und indirekten) Basisschnittstellen muss eine implementierende
(nicht abstrakte) Klasse die abstrakten Methoden aller Schnittstellen definieren (oder erben).
Statische Interface-Methoden werden nicht vererbt, weder an erweiternde Schnittstellen, noch an
implementierende Klassen (siehe Abschnitt 9.2.3.3).
Abschnitt 9.2 Interfaces definieren 453

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.

9.2.3.1 Abstrakte Instanzmethoden


Durch abstrakte Instanzmethoden werden Verhaltenskompetenzen beschrieben, die implementie-
rende Klassen besitzen müssen. Auf den Methodendefinitionskopf folgt an Stelle des durch ge-
schweifte Klammern begrenzten Rumpfes ein Semikolon, z. B.:
public int compareTo(T o);
Eine abstrakte Instanzmethode ist implizit öffentlich. Die Modifikatoren public und abstract sind
überflüssig, aber erlaubt.

9.2.3.2 Instanzmethoden mit default-Implementierung


Seit Java 8 ist es möglich, Instanzmethoden mit Implementierung (also mit einen ausführbaren Me-
thodenrumpf) in eine Schnittstelle aufzunehmen, wobei der Modifikator default zu verwenden ist.
Das erleichtert das Implementieren einer Schnittstelle und ist bei neuen Schnittstellen uneinge-
schränkt zu begrüßen. Ist die default-Lösung für eine implementierende Klasse nicht akzeptabel,
kann sie durch eine eigene Implementation überschrieben werden. Ein wesentliches Motiv für die
Einführung der default-Methoden bestand darin, die nachträgliche Erweiterung von Schnittstellen
um neue Methoden ohne Nachteil für vorhandene Implementationen zu ermöglichen.

9.2.3.2.1 Erweiterung von Schnittstellen um neue Instanzmethoden


Bis zur Version 7 wurde in Java eine Möglichkeit vermisst, vorhandene Interfaces um neue In-
stanzmethoden zu erweitern, ohne die Binär-Kompatibilität mit implementierenden Altklassen zu
verlieren. Seit Java 8 ist das Problem durch die Erweiterung von Schnittstellen um default-
Methoden gelöst. Altklassen erfüllen auch das erweiterte Interface, weil ihnen die default-
Implementierung zur Verfügung steht.
Wir betrachten ein einfaches Beispiel mit einem Interface WinterFace1
interface WinterFace1 {
void sagA();
}
454 Kapitel 9 Interfaces

und einer implementierenden Klasse Impl1:


class Impl1 implements WinterFace1 {
public void sagA() {
System.out.println("A");
}

static public void main(String[] args) {


Impl1 obj = new Impl1();
obj.sagA();
}
}
Nun soll die Schnittstelle WinterFace1 um eine Instanzmethode sagB() erweitert werden, ohne
alte Klassen (z. B. Impl1) ändern zu müssen. Man ergänzt eine Instanzmethode mit dem Modifika-
tor default und einer kompletten Implementierung:
interface WinterFace1 {
void sagA();

default void sagB() {


sagA();
System.out.println("B");
}
}
In einer default-Methode dürfen andere Schnittstellenmethoden verwendet werden (instanzbezoge-
ne und statische, öffentliche und private).
In einer abgeleiteten (erweiternden) Schnittstelle kann eine geerbte default-Methode ...
• durch eine eigene default-Implementierung überschrieben
• oder durch eine abstrakte Definition ersetzt werden.
Implementiert eine bestehende Klasse eine neuerdings um eine default-Methode erweiterte Schnitt-
stelle, dann bleibt die Klasse binärkompatibel zum erweiterten Interface. Trotzdem sind uner-
wünschte Effekte der Schnittstellenerweiterung möglich (siehe Abschnitt 9.2.3.2.2).
Eine neue bzw. aktualisierte Klasse, die das Interface implementiert, kann die default-Methode
unverändert nutzen oder durch eine eigene Implementierung überschreiben. Im folgenden Beispiel
wird die erste Option verwendet:
Quellcode Ausgabe
class Impl2 implements WinterFace1 { A
public void sagA() { B
System.out.println("A");
}

static public void main(String[] args) {


Impl2 obj = new Impl2();
obj.sagB();
}
}

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.:

9.2.3.2.2 Unerwünschte Effekte auf bestehende Klassen


In diesem Abschnitt wird unter Verwendung eines Beispiels aus Kreft & Langer (2014) demons-
triert, dass die Erweiterung einer Schnittstelle um eine default-Instanzmethode das Verhalten einer
vorhandenen implementierenden Klasse ändern kann, wenn die Klasse nach der Schnittstellener-
weiterung neu übersetzt wird.
In der Klasse Nest, die das Interface Kuckuck implementiert, existiert die Instanzmethode
tuWas() mit einem Parameter vom Typ long. In der main() - Methode von Nest wird die Metho-
de mit einem int-Argument aufgerufen, das der Compiler implizit erweiternd in den Typ long wan-
delt:
interface Kuckuck {
void sagWas();
}

class Nest implements Kuckuck {


public void sagWas() {
System.out.println("Was");
}

void tuWas(long par) {


System.out.println("Methode in Nest: " + par);
}

static public void main(String[] args) {


Nest obj = new Nest();
obj.tuWas(3);
}
}
456 Kapitel 9 Interfaces

Nun wird das implementierte Interface um eine default-Methode namens tuWas() mit einem Pa-
rameter vom Typ int erweitert:
interface Kuckuck {
void sagWas();

default void tuWas(int par) {


System.out.println("default-Methode in Kuckuck: " + par*par);
}
}
Wird nur Kuckuck neu übersetzt, Nest hingegen nicht, dann bleibt das Verhalten der Klasse un-
verändert. Insofern wird das folgende Versprechen aus der Sprachbeschreibung von Java 8 einge-
halten (Gosling et al. 2021, Abschnitt 13.5.7):
Adding a default method, or changing a method from abstract to default, does not break compa-
tibility with pre-existing binaries.
Wenn aber auch Nest neu übersetzt wird, bevorzugt der Compiler die besser zum Aktualparameter
passende default-Methode aus dem Interface an Stelle der klasseneigenen Methode. Anschließend
zeigt Nest ein abweichendes Verhalten.
Das im aktuellen Abschnitt beschriebene Risiko ist aber nicht auf die in Java 8 eingeführten
default-Methoden beschränkt, sondern besteht bei jeder Erweiterung einer Klasse um eine neue
Methode, sofern abgeleitete Klassen vorhanden sind. Dementsprechend war das Risiko immer
schon in Java und vergleichbaren objektorientierten Programmiersprachen vorhanden.
Bloch (2018, S. 104f) beschreibt den Fall einer Klasse namens SynchronizedCollection, die das
Interface Collection<E> (siehe Abschnitt 0) implementiert, das in Java 8 die default-Methode
removeIf() zum bedingungsabhängigen Entfernen eines Elements erhalten hat. Die Klasse
SynchronizedCollection verspricht Thread-Sicherheit für ihre Methoden, besitzt aber nun eine
zusätzliche Methode, die nicht thread-sicher ist. Man kann erwarten, dass wichtige Bibliotheken
ihre Klassen an veränderte Schnittstellen anpassen, die kritische default-Methoden erhalten haben,
so wie es im Java-API geschehen ist. Generell ist die nachträgliche Erweiterung einer Schnittstelle
um default-Methoden ein Notbehelf, der möglichst selten genutzt werden sollte, denn Bloch warnt
zu Recht (2018, S. 194):
But it is not always possible to write a default method that maintains all invariants of every con-
ceivable implementation.

9.2.3.3 Statische Methoden


Seit Java 8 sind in Schnittstellen auch statische Methoden erlaubt, wobei eine Implementation er-
forderlich ist. Die im Abschnitt 9.2.3.2 vorgestellte Schnittstelle WinterFace1 soll eine statische
Methode erhalten, welche in der default-Instanzmethode sagB() derselben Schnittstelle genutzt
wird:
interface WinterFace1 {
static void achtung() {
System.out.println("Achtung Durchsage:");
}

void sagA();

default void sagB() {


achtung();
System.out.println("B");
}
}
Abschnitt 9.2 Interfaces definieren 457

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");
}

public static void main(String[] args) {


// achtung(); klappt nicht!
WinterFace1.achtung();
}
}
Auf diese Weise wird verhindert, dass sich durch die Aufnahme von statischen Methoden in ein
Interface das Verhalten von Klassen ändert, welche das Interface implementieren (Kreft & Langer
2014).
Nachdem die technischen Details zu statischen Interface-Methoden geklärt sind, sollte die Frage
adressiert werden, was die Java-Designer bewogen hat, in die achte Version der Programmierspra-
che eine Erweiterung einzubauen, auf die offenbar viele Jahre lang verzichtet werden konnte. Di-
verse Optionen und Varianten erhöhen zwar die Flexibilität einer Programmiersprache, erschweren
aber das Erlernen der Sprache und die Verständlichkeit des Quellcodes. Ein Nutzen von statischen
Interface-Methoden besteht darin, dass für die im Zusammenhang mit einem Interface benötigten
statischen Methoden keine zusätzliche Klasse definiert werden muss.1 So lässt sich bei der Definiti-
on einer Bibliothek die Anzahl der Typen und damit die Komplexität etwas reduzieren.

9.2.3.4 Private Interface-Methoden


Seit Java 9 können Schnittstellenmethoden mit Implementation als private deklariert werden. In-
dem mehrfach benötigte Implementierungsdetails in eine private Schnittstellenmethode ausgelagert
werden, ...
• vermeidet man Code-Wiederholungen
• und verhindert gleichzeitig Zugriffe durch fremde Typen.
Eine private statische Methode kann sowohl von anderen statischen Methoden als auch von
default-Methoden der Schnittstelle aufgerufen werden. Eine private Instanzmethode kann hingegen
nur von default-Methoden der Schnittstelle aufgerufen werden. Im folgenden Beispiel wird eine
private statische Methode von zwei öffentlichen Instanzmethoden mit default-Implementation be-
nutzt:

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:");
}

default void sagA() {


achtung();
System.out.println("A");
}

default void sagB() {


achtung();
System.out.println("B");
}
}

Nachdem Schnittstellen in Java 8 um Instanzmethoden mit default-Implementation sowie um stati-


sche Methoden erweitert worden sind, war es folgerichtig, private Methoden aufzunehmen, um
Code-Wiederholungen zu vermeiden. Die private Realisation von Schnittstellen-Instanzmethoden
ist aus der Sicht von implementierenden Klassen nicht unbedingt als Einschränkung zu beurteilen,
sondern verhindert die eventuell unerwünschte Erweiterung des übernommenen Verhaltensreper-
toires.

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).

9.2.5 Statische Mitgliedstypen


In einer Interface-Definition können Mitgliedstypen (Klassen oder Schnittstellen) definiert werden.
Diese sind generell öffentlich und statisch, wobei die überflüssigen Modifikatoren public und static
erlaubt sind, aber weggelassen werden sollten (Gosling et al. 2021, Abschnitt 9.5). Statische Mit-
gliedstypen verhalten sich wie Top-Level - Typen, müssen jedoch über einen Doppelnamen ange-
sprochen werden, wobei auf den Namen der Schnittstelle ein Punkt und der Name des Mitgliedstyps
folgt.
Als Beispiel betrachten wir das generische API-Interface Map<K,V>, das Methoden für Container
zur Verwaltung von (Schlüssel-Wert) - Paaren festlegt (siehe Abschnitt 10.7.1). Es enthält das inne-
re Interface Map.Entry<K,V>, das die Kompetenzen eines einzelnen (Schlüssel-Wert) - Paares
beschreibt:
public interface Map<K,V> {
int size();
boolean isEmpty();
. . .
interface Entry<K,V> {
K getKey();
V getValue();
. . .
}
. . .
}
Verwendung findet Map.Entry<K,V> z. B. als Rückgabetyp für die im Interface Navigable-
Map<K,V>, das (via SortedMap<K,V>) von Map<K,V> abstammt (siehe Abschnitt 10.7.3), de-
finierte Methode firstEntry():
Map.Entry<K,V> firstEntry()
Das folgende Programm demonstriert die Verwendung eines Objekts, das die generische Schnitt-
stelle Map.Entry<K,V> erfüllt, wobei ein Objekt der generischen Klasse TreeMap<K,V> (siehe
Abschnitt 10.7.4) zum Einsatz kommt.
Quellcode Ausgabe
import java.util.*; AAA
class Prog {
public static void main(String[] args) {
NavigableMap<Integer, String> m = new TreeMap<>();
m.put(1, "AAA");
Map.Entry<Integer, String> me = m.firstEntry();
System.out.println(me.getValue());
}
}

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

9.2.6 Lokale Schnittstellen


Die in Java 16 eingeführten Record-Klassen (siehe Abschnitt 5.5) sind ebenso implizit statisch wie
Schnittstellen. Weil die Record-Klassen auch lokal (in einer Methode) definiert werden dürfen, hat
man der Konsistenz halber in Java 16 das Verbot von lokalen Schnittstellen aufgegeben. Weil eine
lokale Schnittstelle implizit statisch ist, dürfen ihre Methoden nicht auf Variablen oder Methoden
der umgebenden Methode zugreifen. Lokale Schnittstellen werden vermutlich keine große Bedeu-
tung erlangen, sodass auf ein Beispiel verzichtet wird.

9.2.7 Zugriffsschutz bei Mitgliedern von Schnittstellen


Welche Zugriffsmodifikatoren im Kopf einer Schnittstellendefinition erlaubt sind, wurde im Ab-
schnitt 9.2.1 erläutert. Für die Mitglieder von Schnittstellen ist die öffentliche Verfügbarkeit vor-
eingestellt, also insbesondere auch für die Mitgliedstypen (vgl. Abschnitt 9.2.5), z. B.:
public interface Map<K,V> {
. . .
interface Entry<K,V> {
. . .
}
}
Der Modifikator public ist überflüssig und wird in der Regel weggelassen.
Seit Java 9 ist für (statische) Methoden in Schnittstellen auch der Zugriffsmodifikator private er-
laubt (siehe Abschnitt 9.2.3.4).

9.2.8 Marker - Interfaces


Es sind auch Schnittstellen erlaubt, die weder Methoden noch sonstige Bestandteile enthalten, also
nur aus einem Namen bestehen und gelegentlich als marker interfaces bezeichnet werden. Ein be-
sonders wichtiges Beispiel ist die beim Sichern und bei der Netzwerkübertragung kompletter Ob-
jekte (siehe Abschnitt 14.6) relevante API-Schnittstelle Serializable im Paket java.io, die z. B. von
der Klasse java.lang.Double implementiert wird (siehe Abschnitt 9.1.1):
public interface Serializable {
}
Durch das Implementieren dieser Schnittstelle teilt eine Klasse mit, dass sie gegen das Serialisieren
ihrer Objekte nichts einzuwenden hat.

9.3 Interfaces implementieren


Soll für die Objekte einer Klasse angezeigt werden, dass sie den Datentyp einer bestimmten
Schnittstelle erfüllen, dann muss diese Schnittstelle im Kopf der Klassendefinition nach dem
Schlüsselwort implements aufgeführt werden. Als Beispiel dient eine Klasse namens Figur, die
nur partielle Ähnlichkeit mit einem gleichnamigen Beispiel aus Kapitel 7 besitzt und der Einfach-
heit halber die Datenkapselung sträflich vernachlässigt. Sie implementiert das Interface
Comparable<Figur>, damit für Figur-Objekte eine Anordnung definiert ist:
Abschnitt 9.3 Interfaces implementieren 461

package fimpack;

public class Figur implements Comparable<Figur> {


public int xpos, ypos;
public String name;

public Figur(String name_, int xpos_, int ypos_) {


name = name_; xpos = xpos_; ypos = ypos_;
}

public int compareTo(Figur fig) {


if (xpos < fig.xpos)
return -1;
else if (xpos == fig.xpos)
return 0;
else
return 1;
}
}

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.

9.3.1 Mehrere Schnittstellen implementieren


Während eine Klasse nur eine direkte Basisklasse besitzt, kann sie beliebig viele Schnittstellen im-
plementieren, was z. B. die Klasse TreeSet<E> aus dem Java Collections Framework tut:
public class TreeSet<E> extends AbstractSet<E>
implements NavigableSet<E>, Cloneable, Serializable {
. . .
}

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).

9.3.2 Geerbte Interface-Implementationen


Im Zusammenhang mit dem Thema Vererbung ist von Bedeutung, dass eine abgeleitete Klasse die
in Basisklassen implementierten Schnittstellen erbt. Wird z. B. eine Klasse Kreis unter Verwen-
dung des Schlüsselworts extends von der oben vorgestellten Klasse Figur abgeleitet,
package fimpack;

public class Kreis extends Figur {


public int radius;
public Kreis(String name_, int xpos_, int ypos_, int rad_) {
super(name_, xpos_, ypos_);
radius = rad_;
}
}
dann übernimmt sie auch die Implementation der Schnittstelle Comparable<Figur>, und die stati-
sche sort() - Methode der Klasse java.util.Arrays kann auf Felder mit Kreis-Elementen ange-
wendet werden, z. B.:
464 Kapitel 9 Interfaces

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);

java.util.Arrays.sort(ka, new Comparator<Kreis>() {


@Override
public int compare(Kreis o1, Kreis o2) {
if (o1.xpos + o1.radius < o2.xpos + o2.radius)
return -1;
else if (o1.xpos + o1.radius == o2.xpos + o2.radius)
return 0;
else
return 1;
}
});
for (Kreis ko : ka) System.out.print(ko.name + " ");
}
}

9.3.3 Implementieren von Schnittstellen und Sichtbarkeit von Klassen


Durch das Implementieren einer öffentlichen Schnittstelle wird eine mit dem voreingestellten paket-
internen Zugriff definierte Klasse im Rahmen der Interface-Implementation public, d .h. die per
Schnittstellenreferenzvariable ansprechbaren Methoden können nicht nur im eigenen Paket, sondern
in allen berechtigten Paketen genutzt werden, bei Verzicht auf die ab Java 9 mögliche Modultech-
nik also in allen Paketen. Zur Demonstration sind einige Details erforderlich, und beim ersten Lesen
kann der Rest des Abschnitts übersprungen werden.
Wir definieren im Paket meinpaket die Schnittstelle IFace mit public-Zugriff:
package meinpaket;

public interface IFace {


void tell();
}

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;

class InTell implements IFace {


@Override
public void tell() {
System.out.println("IFace-Implementation von Klasse InTell");
}

void tellPint() {
System.out.println("Eigene Methode von Klasse Intell");
}
}
466 Kapitel 9 Interfaces

Die IFace-Schnittstellenmethoden haben den voreingestellten public-Zugriff, und diese Zugriff-


stufe darf beim Implementieren nicht eingeschränkt werden (siehe Abschnitt 9.3).
Im Paket existiert weiterhin eine öffentliche Klasse, die als Objektfabrik über die statische Methode
make() u. a. InTell-Objekte liefern kann:
package meinpaket;

public class Faktory {


public static IFace make(int sorte) {
if (sorte == 1)
return new ExTell();
else
return new InTell();
}
}

Nach diesen Vorbereitungen lässt sich ein Objekt der Klasse InTell auch in Methoden fremder
Pakete einsetzen, z. B.:
package sichtbarkeit;
import meinpaket.*;

public class Main {


public static void main(String[] args) {
IFace inTell = Faktory.make(2);
inTell.tell();
// meinpaket.InTell direkt = new meinpaket.InTell();
// inTell.tellPint();
}
}
Allerdings ist dabei eine IFace-Referenz zu verwenden, sodass nur die Methoden dieser Schnitt-
stelle aufgerufen werden können. Der Intell-Konstruktor und die Intell-Methode
tellPint() sind nur paketintern verwendbar.

9.4 Interfaces als Referenzdatentypen verwenden


Mit der Definition einer Schnittstelle wird ein neuer Referenzdatentyp vereinbart, der anschließend
in Variablen- und Parameterdeklarationen verwendbar ist. Eine Referenzvariable des neuen Typs
kann auf Objekte jeder Klasse zeigen, die die Schnittstelle implementiert, z. B.:
Quellcode Ausgabe
interface Quatsch { Bin ein Ritter.
void sagWas(); Bin ein Wolf.
}

class Ritter implements Quatsch {


void ritterlichesVerhalten() { ... }
public void sagWas() {System.out.println("Bin ein Ritter.");}
}
class Wolf implements Quatsch {
public void jagdausflug() { ... }
public void sagWas() {System.out.println("Bin ein Wolf.");}
}

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)

9.5 Versiegelte Interfaces


Seit Java 17 kann man eine Schnittstelle über den Modifikator sealed als versiegelt definieren und
am Ende des Definitionskopfs nach dem Schlüsselwort permits ...
• Klassen auflisten, die die versiegelte Schnittstelle implementieren dürfen
• und/oder Schnittstellen auflisten, die die versiegelte Schnittstelle erweitern dürfen.
Wir erlauben uns der Bequemlichkeit halber ein Nonsens-Beispiel nach dem Muster aus dem Ab-
schnitt 9.4:
package de.uni_trier.zimk.sealdemo;

sealed interface Quatsch permits Wolf, Ritter {


void sagWas();
}

non-sealed class Wolf implements Quatsch{


public void sagWas() {System.out.println("Bin ein Wolf.");}
}

final class Ritter implements Quatsch {


public void sagWas() {System.out.println("Bin ein Ritter.");}
}
In der Definition der versiegelten Schnittstelle Quatsch werden nach dem Schlüsselwort permits
zwei implementierende Klassen erlaubt. Wenn sich die beiden Klassen zusammen mit der versie-
gelten Schnittstelle in einer gemeinsamen Quellcodedatei befinden, dann darf die permits-Klausel
entfallen:
468 Kapitel 9 Interfaces

sealed interface Quatsch {


void sagWas();
}

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;
};
}

public static void main(String[] args) {


Quatsch[] qar = {new Wolf(), new Ritter()};
for (Quatsch q : qar)
System.out.println(bewerte(q));
}
}
Ein default-Fall wäre sinnlos, weil seine Zuweisung nie ausgeführt würde.

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

public @interface VersionInfos {


String version();
int build();
String date() default "unknown";
String[] contributors() default {};
}
Als Rückgabetypen sind bei Annotationselementen erlaubt:
• Primitive Typen
• Die Klassen String und Class
• Aufzählungstypen
• Annotationstypen
• Arrays mit einem Elementtyp aus der vorgenannten Liste
Parameter dürfen bei einem Annotationselement nicht vereinbart werden.
Nach dem Schlüsselwort default kann zu einem Annotationselement ein Voreinstellungswert ange-
geben werden. Dies spart Aufwand bei der Annotationsvergabe (siehe Abschnitt 9.6.2), wenn der
Voreinstellungswert gerade passt, weil man in diesem Fall das Annotationselement weglassen kann.
Elemente ohne default-Wert müssen bei der Zuweisung einer Annotation mit Werten versorgt wer-
den.
Um bei einem Annotationselement mit Array-Typ einen leeren Array als Voreinstellung zu verein-
baren, setzt man hinter das Schlüsselwort default ein Paar geschweifter Klammern (also eine leere
Initialisierungsliste), z. B.:
String[] contributors() default {};
Hat eine Annotation nur ein einziges Element, dann sollte dieses Element den Namen value() erhal-
ten. Im folgenden Beispiel ist die API-Annotation Retention (aus dem Paket java.lang.annotation)
zu sehen, deren einziges Element den Aufzählungstyp RetentionPolicy besitzt:
public @interface Retention {
RetentionPolicy value();
}
Dann genügt bei der Zuweisung (siehe Abschnitt 9.6.2) an Stelle der (Name = Wert) - Notation eine
Wertangabe, z. B.:
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.SOURCE)
public @interface Override {
}

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
}

Für die Elementenliste einer Annotation gelten folgende Regeln:


• Sie wird durch runde Klammern begrenzt.
• Sie kann bei Marker-Annotationen (ohne Elemente) entfallen, z. B.:
@Override
• Ist nur ein Element namens value vorhanden, dann genügt die Wertangabe, z. B.:
@Retention(RetentionPolicy.SOURCE)
• Elemente mit default-Wert dürfen weggelassen werden. Im Beispiel VersionInfos ist der
Verzicht auf eine Datumsangabe erlaubt, weil das zugehörige Annotationselement einen de-
fault-Wert besitzt.
• Sind ausschließlich Annotationselemente mit default-Werten vorhanden, dann kann die
Elementenliste wie bei einer Marker-Annotation entfallen, z. B.:
@Deprecated
• Als Werte sind nur konstante Ausdrücke erlaubt, die der Compiler berechnen kann.
• Bei Elementen mit Referenztyp ist der Wert null verboten.
• Sind bei einem Annotationselement mit Array-Typ mehrere Werte zu vergeben, werden die-
se mit geschweiften Klammern begrenzt, z. B.:
contributors = {"Häcker", "Kwikki"}
Bei einem einzelnen Wert sind keine geschweiften Klammern erforderlich, z. B.:
contributors = "Häcker"

9.6.3 Runtime-Annotationen per Reflexion auswerten


Soll eine Annotation zwecks Auswertung per Reflexion auch noch zur Laufzeit an einem Träger
haften, dann muss bei ihrer Definition die Meta-Annotation Retention (vgl. Abschnitt 9.6.4) ent-
sprechend gesetzt werden:
@Retention(RetentionPolicy.RUNTIME)
Diese Zuweisung eignet sich auch für die im Abschnitt 9.6.1 vorgestellten Annotation
VersionInfos, die im folgenden Beispielprogramm zum Einsatz kommt:
472 Kapitel 9 Interfaces

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
}

public static void main(String[] args) {


VersionInfos vi;
for (Method meth : AnnoReflection.class.getMethods()) {
System.out.println("\npublic method "+meth.getName()+"()");
vi = meth.getAnnotation(VersionInfos.class);
if (vi != null) {
System.out.println(" "+vi.version()+" ("+vi.build()+") "+vi.date());
for (String s : vi.contributors())
System.out.print(" "+s);
System.out.println();
}
}
}
}
Im Beispiel werden die Elementausprägungen der zur Methode meth() der Klasse AnnoRe-
flection gehörigen VersionInfos-Instanz folgendermaßen ermittelt:
• Über das an den Klassennamen AnnoReflection per Punktoperator angehängte Schlüs-
selwort class wird ein Objekt der Klasse Class angesprochen, das diverse Kenntnisse über
die Klasse AnnoReflection besitzt:
AnnoReflection.class
Dasselbe Class-Objekt wird übrigens auch von der Instanzmethode getClass() geliefert.
• Mit der Class-Methode getMethods() erhält man einen Array mit Objekten der Klasse Me-
thod für alle öffentlichen Methoden der Klasse AnnoReflection (selbst definiert oder ge-
erbt von der Basisklasse oder von einem implementierten Interface mit default-Methoden):
AnnoReflection.class.getMethods()
• Ein Method-Objekt kann mit der Methode getAnnotation() aufgefordert werden, ggf. eine
Referenz zu der per Parameter vom Typ Class spezifizierten Annotation zu liefern:
VersionInfos vi = meth.getAnnotation(VersionInfos.class);
• Nun lassen sich die Werte der Annotationselemente ermitteln, z. B.:
vi.version()
Bei der Ausführung des Beispielprogramms erfährt man über die Methode meth() der Klasse
AnnoReflection:
public method meth()
7.1.4 (3124) unknown
Häcker Kwikki

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);
}

Im Paket java.lang.annotation finden sich wichtige Meta-Annotationen, welche z. B. die erlaubte


Verwendung oder den Gültigkeitsbereich einer Annotation betreffen:
• Documented
Die Vergabe einer so dekorierten Annotation sollte in einem Dokumentationskommentar er-
läutert werden.
• Inherited
Eine so dekorierte Annotation wird von einer Klasse an ihre Ableitungen vererbt.
• Retention
Über einen Wert vom Aufzählungstyp java.lang.annotation.RetentionPolicy wird festge-
legt, wo eine Annotation verfügbar sein soll:
o SOURCE
Die Annotation ist nur in der Quellcodedatei vorhanden.
o CLASS (= Voreinstellung)
Die Annotation ist auch in der Bytecode-Datei vorhanden, aber zur Laufzeit nicht
verfügbar.
o RUNTIME
Die Annotation ist auch zur Laufzeit verfügbar.
Abschnitt 9.6 Annotationen 475

Um für eine Annotation die im Abschnitt 9.6.3 beschriebene Reflexion zu ermöglichen,


muss sie bei der Meta-Annotation Retention den Wert RUNTIME erhalten.
• Target
Über einen Array mit Werten vom Aufzählungstyp java.lang.annotation.ElementType
wird festgelegt, für welche Programmelemente eine Annotation verwendbar ist. Z. B. kann
eine folgendermaßen dekorierte Annotation
@Target({ElementType.CONSTRUCTOR, ElementType.METHOD})
einer Methode oder einem Konstruktor zugewiesen werden.

9.6.5 Annotationen bei Record-Klassen


Bei den in Java 16 eingeführten Record-Klassen (siehe Abschnitt 5.5) genügt in der Regel eine sehr
knappe Definition, die als obligatorische Bestandteile das Schlüsselwort record, den Typnamen,
eine Liste mit den Komponenten und einen (oft leeren) Rumpf enthält, z. B.:
public record Point(int x, int y) {}
Zu einer Record-Komponente erstellt der Compiler:
• ein finalisiertes, privates Feld
• eine lesende, öffentliche Zugriffsmethode
• einen Parameter des kanonischen Konstruktor
Wird eine Record-Komponente annotiert,
public record Point(@RecordAnno int x, int y) {}
dann kann sich die Annotation auf verschiedene Mitglieder der Record-Klasse (auch auf mehrere)
beziehen. Die bei der Definition der Annotation verwendete Meta-Annotation Target (siehe Ab-
schnitt 9.6.1) entscheidet darüber, welche Record-Mitglieder betroffen sind, z. B.:
• @Target(ElementType.RECORD_COMPONENT})
Die Annotation wird auf die Record-Komponente angewendet.
• @Target(ElementType.FIELD)
Die Annotation wird auf das Feld zur Record-Komponente angewendet.
• @Target(ElementType.METHOD)
Die Annotation wird auf die Zugriffsmethode zur Record-Komponente angewendet.
• @Target(ElementType.PARAMETER)
Die Annotation wird auf den Konstruktorparameter zur Record-Komponente angewendet,
sofern kein expliziter Konstruktor mit expliziten Parametern definiert wird (siehe Abschnitt
5.5.2.2).
• @Target({ElementType.FIELD, ElementType.PARAMETER})
Die Annotation wird auf das Feld und auf den Konstruktorparameter zur Record-
Komponente angewendet.
Um die etwas komplexe Zuordnung von Annotationen zu Record-Mitgliedern zu demonstrieren und
gleichzeitig erneut die Reflexion zu üben, betrachten wir ein Beispiel mit der Annotation
RecordAnno,
import java.lang.annotation.*;

@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

public record Point(@RecordAnno int x, int y) {


public Point {
if(x < 0 || y < 0) {
throw new IllegalArgumentException("Koordinaten müssen positiv sein.");
}
}
}
und mit der folgenden Hauptklasse:
class Prog {
public static void main(String[] args) {
var p = new Point(5, 10);
RecordAnno da;

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());
}
}
}

Das Programm ermittelt zur Record-Klasse Point ...


• die Record-Komponenten mit der Methode getRecordComponents()
Point.class.getRecordComponents()
• die Methoden mit der Methode getMethods()
Point.class.getMethods()
• die Felder mit der Methode getDeclaredFields()
Point.class.getDeclaredFields()
• die Konstruktoren mit der Methode getConstructors() und die Parameter des ersten
Konstruktors mit der Methode getParameters()
Point.class.getConstructors()[0].getParameters()
Es resultiert die Ausgabe:
Abschnitt 9.7 Übungsaufgaben zum Kapitel 9 477

Annotierte Record-Komponenten:
x

Annotierte Methoden:
x()

Annotierte Felder:
x

Annotierte Konstruktorparameter:
x

9.7 Übungsaufgaben zum Kapitel 9


1) Welche der folgenden Aussagen sind richtig bzw. falsch?
1. Eine Schnittstelle ist per Voreinstellung (ohne Zugriffsmodifikator) als public definiert.
2. Die Methoden einer Schnittstelle sind per Voreinstellung (ohne Zugriffsmodifikator) als
public definiert.
3. Eine Schnittstelle muss mindestens eine Methode enthalten.
4. Die Felder einer Schnittstelle sind implizit als public, static und final deklariert.
5. Annotationen sind spezielle Schnittstellen.

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

und dann dem Projekt zugewiesen werden:


478 Kapitel 9 Interfaces

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.

10.1 Arrays versus Kollektionen


Auch die im Abschnitt 5.1 vorgestellten Arrays taugen als Container für (angeordnete) Elemente
mit einem identischen, frei wählbaren Typ und bieten zudem einen schnellen Indexzugriff auf die
enthaltenen Elemente. Man kann sich fragen, wozu eigentlich noch weitere Kollektionsklassen be-
nötigt werden.
Bei den in vielen Anwendungen erforderlichen listenartigen Datenstrukturen zeigen die Arrays fol-
gende Schwächen:
• Die Größe eines Arrays kann nach der Erzeugung nicht mehr geändert werden.
• Soll ein neues Element an der kleinen oder mittleren Position k eingefügt werden, dann
müssen alle vorhandenen Elemente ab dieser Position aufwändig nach rechts verschoben
werden. Ein analoger Aufwand entsteht beim Löschen eines Elements an der Position k. In
diesem Fall müssen alle Elemente an den Positionen > k nach links verschoben werden, um
eine Lücke zu vermeiden.
480 Kapitel 10 Java Collections Framework

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

• public static <E> List<E> asList(E... a)


Die statische und generische Methode asList() der Klasse Arrays erstellt aus dem Array,
der hinter dem Serienparameter der Methode steckt (vgl. Abschnitt 4.3.1.4.3), ein Objekt
vom Typ List<E>.1
• public <T> T[] toArray(T[] a)
Das Interface Collection<E> verlangt von implementierenden Klassen die generische
Instanzmethode toArray(), die einen Array mit den Elementen der Kollektion liefert (siehe
Abschnitt 10.3).

10.2 Zur Rolle von Schnittstellen beim JCF-Design


Wie bei jedem Framework spielen auch beim JCF Schnittstellen eine wichtige Rolle. Im Kapitel 9
haben Sie erfahren, dass ein Interface meist aus einer Liste von Instanzmethoden besteht, die ent-
weder abstrakt definiert sind oder eine default-Implementierung besitzen.
Das JCF enthält ...
• für alle Datenstrukturen (Kollektion, Liste, Menge, Abbildung, Warteschlange) jeweils eine
Schnittstelle mit einer Liste von Methoden, die jede Lösung zu erfüllen hat,
• und für jede Schnittstelle verschiedene Implementierungen, die trotz unterschiedlicher In-
nenarchitekturen dieselben Methoden beherrschen, sodass problemadäquat ein einfacher
Wechsel möglich ist.
Anschließend ist das im Abschnitt 10.3 zu beschreibende Interface Collection<E> aus dem Paket
java.util zu sehen, das grundlegende Kompetenzen einer Kollektionsklasse vorschreibt:2
public interface Collection<E> extends Iterable<E> {
boolean add(E e);
boolean addAll(Collection<? extends E> c);
void clear();
boolean contains(Object o);
boolean containsAll(Collection<?> c);
boolean equals(Object o);
int hashCode();
boolean isEmpty();
Iterator<E> iterator();
default Stream<E> parallelStream() {
return StreamSupport.stream(spliterator(), true);
}
boolean remove(Object o);
boolean removeAll(Collection<?> c);
default boolean removeIf(Predicate<? super E> filter) {
. . .
return removed;
}

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

boolean retainAll(Collection<?> c);


int size();
@Override
default Spliterator<E> spliterator() {
return Spliterators.spliterator(this, 0);
}
default Stream<E> stream() {
return StreamSupport.stream(spliterator(), false);
}
Object[] toArray();
<T> T[] toArray(T[] a);
}

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>

List<E> Set<E> Queue<E> Map<K,V>

SortedSet<E> Deque<E> SortedMap<K,V>

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

10.3 Das Interface Collection<E>


In diesem Abschnitt werden die vom Interface Collection<E> diktierten generellen Anforderungen
an eine Kollektionsklasse beschrieben. Es werden aber auch die von einer sinnvollen Konkretisie-
rung des Typformalparameters E erwarteten Eigenschaften behandelt.

10.3.1 Basiskompetenzen einer Kollektion


Implementiert eine Klasse direkt oder indirekt das generische Interface Collection<E>, dann muss
sie u. a. die folgenden Instanzmethoden beherrschen:1
• public boolean add(E element) (optionale Operation)
Wenn die Kollektion aufgrund der per add() beantragten Neuaufnahme verändert wurde,
dann liefert die Methode den Rückgabewert true. Manche Kollektionen verweigern die
Aufnahme von Dubletten und liefern ggf. den Rückgabewert false. Scheitert die Aufnahme
aus einem anderen Grund (z. B. Ablehnung von null-Elementen), dann wirft die Methode
eine Ausnahme (z. B. vom Typ NullPointerException).2 Wie für Arrays gilt auch für Kol-
lektionen, dass sie keine vollständigen Objekte aufnehmen, sondern Referenzen auf Objekte.
• public boolean addAll(Collection<? extends E> collection) (optionale Operation)
Wenn die angesprochene Kollektion aufgrund der beantragten Neuaufnahme einer komplet-
ten Kollektion verändert wurde, dann liefert die Methode den Rückgabewert true. Auf Feh-
ler reagiert addAll() analog zu add() (siehe oben). Durch eine gebundene Wildcard-
Typdeklaration (siehe Abschnitt 8.3) wird für die Aufnahmekandidaten der Elementtyp der
im addAll() - Aufruf angesprochenen Kollektion oder eine Spezialisierung erlaubt. Das
Verhalten der Methode addAll() ist undefiniert, wenn während ihrer Ausführung die Para-
meterkollektion (z. B. durch einen anderen Thread) verändert wird.
• public void clear() (optionale Operation)
Mit dieser Methode fordert man eine Kollektion auf, alle Elemente aus der Kollektion zu
entfernen. Dabei werden die im Kollektionsobjekt vorhandenen Objektreferenzen gelöscht,
nicht die Objekte selbst.3
• public boolean contains(Object o)
Diese Methode informiert darüber, ob das Parameterobjekt in der angesprochenen Kollekti-
on enthalten ist. Von einer Implementierung wird erwartet, die Rückgabe true genau dann
zu liefern, wenn für mindestens ein Elemente e der angesprochenen Kollektion der folgende
Aufruf der statischen equals() - Methode der Klasse Objects den Wert true besitzt:
Objects.equals(o, e)
Wenn beide Parameter gleich null sind, dann liefert Objects.equals() die Rückgabe true. Ist
nur der erste Parameter gleich null, dann liefert Objects.equals() die Rückgabe false. An-
sonsten wird die equals() - Instanzmethode des ersten Parameterobjekts aufgerufen. Eine
implementierende Klasse muss aber zur Existenzprüfung nicht unbedingt für alle
Kollektionselemente die Methode Objects.equals() aufrufen, sondern kann ihre
Besonderheiten ausnutzen, um das Ergebnis mit weniger Aufwand zu ermitteln (siehe
Abschnitt 10.6.3 zu dem von der Klasse HashSet<E> verwendeten Verfahren).
• public boolean containsAll(Collection<?> collection)
Diese Methode informiert darüber, ob die angesprochene Kollektion alle Elemente der Pa-
rameterkollektion enthält. Zum exakten Existenzkriterium siehe die Erläuterungen zur Me-
thode contains().

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

• public boolean isEmpty()


Mit dieser Methode findet man heraus, ob die angesprochene Kollektion aktuell leer ist.
• public Iterator<E> iterator()
Diese Methode liefert ein Iterator-Objekt, das ein sequentielles Aufsuchen der Kollektions-
elemente ermöglicht. Sie gehört zum Interface Iterable<E>, das vom Interface Collec-
tion<E> erweitert wird. Details zu Iteratoren folgen im Abschnitt 10.5.
• public default Stream<E> parallelStream()
Diese Methode liefert nach Möglichkeit einen parallelen Strom mit der angesprochenen Kol-
lektion als Quelle (zur Strombearbeitung siehe Abschnitt 12.2). Es ist aber zulässig, statt ei-
nes parallelen Stroms eine sequentielle Variante zu liefern.
• public boolean remove(Object obj) (optionale Operation)
Diese Methode entfernt ggf. aus der Kollektion ein Element, das mit dem Parameterobjekt
nach dem oben für die Methode contains() beschriebenen Kriterium übereinstimmt. Zeigt
der Aktualparameter auf null, dann wird also ggf. ein null-Element aus der Kollektion ent-
fernt. Mit der Rückgabe informiert die Methode darüber, ob die Kollektion tatsächlich geän-
dert wurde.
• public boolean removeAll(Collection<?> collection) (optionale Operation) (optionale
Operation)
Diese Methode entfernt alle Elemente aus der angesprochenen Kollektion, die mit einem
Element der Parameterkollektion nach dem oben für die Methode contains() beschriebenen
Kriterium übereinstimmen. Mit dem Rückgabewert informiert die Methode darüber, ob die
Kollektion geändert wurde. Aufgrund der ungebundenen Wildcard-Typdeklaration (siehe
Abschnitt 8.3) für den Parameter wird jede Klasse akzeptiert, die die generische Schnittstelle
Collection<E> mit beliebigem Referenzelementtyp E implementiert.
• public default boolean removeIf(Predicate<? super E> bedingung)
Diese Methode entfernt ggf. alle Elemente aus der angesprochenen Kollektion, die eine Be-
dingung erfüllen, die durch die Methode test() der funktionalen Schnittstelle Predicate<T>
geprüft wird (zu funktionalen Schnittstellen siehe Abschnitt 12.1.1.1). Mit der Rückgabe in-
formiert die Methode darüber, ob die Kollektion geändert wurde. Durch die super-
gebundene Wildcard-Typdeklaration (siehe Abschnitt 8.3.1.2) werden auch Tester zugelas-
sen, die für Kandidaten nicht den Kollektionselementtyp E vorschreiben, sondern eine Ba-
sisklasse von E.
• public boolean retainAll(Collection<?> collection) (optionale Operation)
Diese Methode entfernt ggf. alle Elemente aus der angesprochenen Kollektion, die keinem
Element der Parameterkollektion nach dem oben für die Methode contains() beschriebenen
Kriterium übereinstimmen. Mit der Rückgabe informiert die Methode darüber, ob die Kol-
lektion geändert wurde.
• public int size()
Diese Methode liefert die aktuelle Anzahl der Elemente in der Kollektion.
• public default Stream<E> stream()
Diese Methode liefert einen sequentiellen Strom mit der angesprochenen Kollektion als
Quelle (zur Strombearbeitung siehe Abschnitt 12.2).
• public Object[] toArray()
Es wird ein Object-Array erstellt, mit den Elementen der Kollektion befüllt und abgeliefert.
Ist für den Iterator der Kollektion eine Ordnung definiert, dann sind die Array-Elemente ent-
sprechend angeordnet.
Abschnitt 10.3 Das Interface Collection<E> 485

• public <T> T[] toArray(T[] a)


Es wird ein Array mit den Elementen der Kollektion geliefert, wobei als Datentyp für diesen
Array der Laufzeittyp des Parameter-Arrays übernommen wird. Es ist zu beachten, dass T
nicht der Typformalparameter von Collection<E> ist, sondern für einen beliebigen kompa-
tiblen Typ steht. Bei toArray(T[] a) handelt es sich um eine generische Methode. Der Lauf-
zeittyp des Parameter-Arrays muss kompatibel sein mit den Laufzeittypen aller Kollektions-
elemente. Wenn eine Typumwandlung scheitert, dann kommt es zu einem Laufzeitfehler
vom Typ ArrayStoreException. Ist die Kapazität des Parameter-Arrays ausreichend, wer-
den die Kollektionselemente dort eingefüllt. Anderenfalls bleibt der Parameter-Array unver-
ändert, und es wird ein neuer Array passender Größe als Rückgabe geliefert.
Die beiden toArray() - Überladungen unterstützen die Kooperation von Kollektionen mit Software,
die Array-Datentypen verwendet (z. B. als Methodenparameter). Die Methoden liefern keine Refe-
renz auf das eventuell von einer Kollektion intern zur Datenablage verwendete Array-Objekt, son-
dern erstellen auf jeden Fall einen neuen Array. Dieser Array enthält als Elemente aber dieselben
Objekt-Adressen wie die Kollektion. Sind die Elemente veränderbar, dann wirken sich per Array-
Index vorgenommene Änderungen also auf die Kollektion aus.
Für alle zu einer Änderung der Kollektion führenden Methoden (z. B. add(), addAll(), clear(), re-
move() usw.) ist in der API-Dokumentation der Zusatz optional operation angegeben (vgl. Ab-
schnitt 0). Es ist einer Klasse erlaubt, sich bei der Implementation solcher Methoden auf das Werfen
einer UnsupportedOperationException zu beschränken. Es wird allerdings von jeder implemen-
tierenden Klasse erwartet, in der Dokumentation offenzulegen, für welche Methoden nur eine Pseu-
do-Implementation vorhanden ist.
Es verwundert leicht, dass bei den Methoden contains() und remove() nicht der Typformalparame-
ter E sondern die Klasse Object als Parametertyp zum Einsatz kommt. Eine überzeugende Erklä-
rung hat Luke Hutteman auf Stack Overflow präsentiert:1 Auf diese Weise können die Methoden
auch dann eingesetzt werden, wenn der unbeschränkte Wildcard-Datentyp verwendet wird, z. B.:
Quellcode Ausgabe
import java.util.*; [A, B]
class Prog { true
public static void main(String[] args) { [A]
List<String> als = new ArrayList<>();
als.add("A"); als.add("B");
System.out.println(als);
Collection<?> colq = als;
System.out.println(colq.contains("B"));
colq.remove("B");
System.out.println(als);
}
}

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

10.3.2 Optionale Operationen


Wer die Dokumentation zur Schnittstelle Collection<E> studiert, stellt verwundert fest, dass sich
bei etlichen Methoden der Zusatz optional operation findet, der aber nicht als optional implementa-
tion missverstanden werden darf:1
boolean add(E e)
Ensures that this collection contains the specified element (optional operation).
boolean addAll(Collection<? extends E> c)
Adds all of the elements in the specified collection to this collection (optional opera-
tion).
void clear()
Removes all of the elements from this collection (optional operation).
... ...

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).

10.3.3 Anforderungen an den Elementtyp


Das Verhalten von Collection<E> - Methoden wie z. B. contains() und remove() bei Überein-
stimmungsprüfungen ist über die equals() - Methode des Elementtyps definiert (siehe Abschnitt
10.3.1). Dementsprechend wird von einer Konkretisierung des Typformalparameters E für die von
Object geerbte equals() - Methode eine sinnvolle Überschreibung erwartet, die eine Äquivalenzre-
lation (im mathematischen Sinn) realisieren muss (vgl. Bloch 2018, S. 38ff), d. h.:

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).

10.4.1 Das Interface List<E>


Zur Realisation von Listen enthält das Java Collections Framework mehrere Klassen mit unter-
schiedlichen Techniken zum Speichern der Elemente, die in verschiedenen Einsatzszenarien stark
abweichende Leistungen zeigen. Alle implementieren das von Collection<E> abstammende Inter-
face List<E> und besitzen folglich über die Collection<E> - Methoden (siehe Abschnitt 10.3) hin-
aus u. a. die folgenden Kompetenzen:
• public void add(int index, E element) (optionale Operation)
Es wird ein neues Element an der gewünschten Indexposition eingefügt. Bei einer Liste mit
aktuell k Elementen sind die Indexwerte 0 bis k erlaubt, wobei der Wert k zum Verlängern
der Liste führt.
• public boolean addAll(int index, Collection<? extends E> coll) (optionale Operation)
Diese Methode fügt die Elemente der Parameterkollektion an der gewünschten Position in
die Liste ein. Bei einer Liste mit aktuell k Elementen sind die Indexwerte 0 bis k erlaubt,
wobei der Wert k zum Verlängern der Liste führt. Wenn die Liste aufgrund des Aufrufs ver-
ändert wurde, dann liefert die Methode den Rückgabewert true.
• public E get(int index)
Das Element mit dem gewünschten Index wird geliefert (wahlfreier Zugriff). Bei einer Liste
mit aktuell k Elementen sind die Indexwerte 0 bis k-1 erlaubt.

1
Man kann ein adressiertes Element ersetzen oder verändern, sofern seine Klasse Änderungen erlaubt.
Abschnitt 10.4 Listen 489

• public int indexOf(Object o)


Sind Elemente vorhanden, die mit dem Parameterobjekt übereinstimmen, dann wird der
kleinste zugehörige Index geliefert, ansonsten der Wert -1. Genaugenommen stimmt das
indexOf() - Parameterobjekt mit dem Listenobjekt an der Position i überein, wenn der fol-
gende Aufruf der statischen equals() - Methode der Klasse Objects den Wert true besitzt:
Objects.equals(o, get(i))
Wenn beide Parameter gleich null sind, dann liefert Objects.equals() die Rückgabe true. Ist
nur der erste Parameter gleich null, dann liefert Objects.equals() die Rückgabe false. An-
sonsten wird die equals() - Instanzmethode des ersten Parameterobjekts aufgerufen.
• public int lastIndexOf(Object obj)
Sind Elemente vorhanden, die mit dem Parameterobjekt übereinstimmen, dann wird der
größte zugehörige Index geliefert, ansonsten der Wert -1. Zum Kriterium für eine Überein-
stimmung siehe die Beschreibung der Methode indexOf().
• public ListIterator<E> listIterator()
Diese Methode liefert einen Iterator, der die von Iterator<E> abgeleitete Schnittstelle
ListIterator<E> erfüllt und daher mehr Kompetenzen besitzt als eine Iterator<E> - Im-
plementation:
o Es dürfen Elemente eingefügt und verändert werden.
o Die Rückwärtsbewegung wird unterstützt.
o Die aktuelle Indexposition kann abgefragt werden.
Es ist noch eine listIterator() - Überladung mit Parameter für die Indexstartposition vor-
handen. Details zu den Iteratoren folgen im Abschnitt 10.5.
• public E remove(int index) (optionale Operation)
Diese Methode entfernt das Element an der Position index aus der Liste und liefert dessen
Adresse zurück. Bei einer Liste mit aktuell k Elementen sind die Indexwerte 0 bis k-1 er-
laubt.
• public E set(int index, E element) (optionale Operation)
Das Element an der im ersten Parameter genannten Position wird durch das Objekt im zwei-
ten Parameter ersetzt. Bei einer Liste mit aktuell k Elementen sind die Indexwerte 0 bis k-1
erlaubt.
• public List<E> subList(int fromIndex, int toIndex,)
Man erhält ein Segment der angesprochenen Liste von der Indexposition fromIndex (inklu-
sive) bis zur Indexposition toIndex (exklusive). Weil nur die Referenzen, aber nicht die refe-
renzierten Listenelemente kopiert werden, wirken sich alle per Subliste durchgeführten Än-
derungen auf die Originalliste aus (z. B. add(), remove(), clear()). Werden nach dem Erstel-
len der Subliste strukturelle Änderungen über die Originalliste vorgenommen, dann führt
die anschließende Verwendung der Subliste zu einem Laufzeitfehler vom Typ
ConcurrentModificationException.
Auf fehlerhafte Aktualparameter (z. B. Indexwerte < 0, inkompatibler Elementtyp) reagieren die
List<E> - Methoden mit einem Ausnahmefehler (z. B. IndexOutOfBoundsException,
ClassCastException).
Die bereits im Interface Collection<E> vorhandenen Methoden add(E element) und ad-
dAll(Collection<? extends E> c) müssen von einer Listenverwaltungsklasse so implementiert wer-
den, dass neue Elemente am Ende der Liste angehängt werden.
Warum die Methoden indexOf() und lastIndexOf() den Formalparametertyp Object (statt E) ver-
wenden, wurde im Abschnitt 10.3 erläutert.
490 Kapitel 10 Java Collections Framework

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
}
}

Die auskommentierte Anweisung hätte einen Laufzeitfehler vom Typ UnsupportedOperation-


Exception zur Folge, weil die of() - Produkte unveränderlich sind.
Den Aufruf der statischen, generischen Methode of() richtet man an den Rohtyp der generischen
Schnittstelle List<E>. Wegen der Typinferenz-Fähigkeiten des Compilers ist es nicht erforderlich,
beim Aufruf der generischen Methode den Typaktualparameter anzugeben:
List<String> los = List.<String>of("a", "b", "c", "d");
Wie die Ausgabe des Programms zeigt, sind die Elemente in einem List<E> - Container in der Ein-
fügereihenfolge angeordnet.
Man sollte nach Möglichkeit für Variablen und Parameter, die auf eine Liste zeigen, den Interface-
Datentyp List<E> verwenden, damit zur Lösung einer konkreten Aufgabe die optimale List<E> -
Implementierung im OCP-Sinn (Open-Closed - Prinzip, vgl. Abschnitt 4.1.1.3), also praktisch ohne
Quellcode-Änderungen, genutzt werden kann.
In Bezug auf die die eingesetzte Technik zum Speichern der Elemente bestehen zwischen den
List<E> - Implementierungen im Java Collections Framework erhebliche Unterschiede, die im Ab-
schnitt 10.4.3 behandelt werden.

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<>();

namen.add("Rita"); namen.add("Otto"); namen.add("Leo"); namen.add(0, "Otto");


System.out.println("Namensliste:");
for(String s : namen)
System.out.println(s);
System.out.println();

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));
}

List<String> unikate = new ArrayList<>();


for(String s : namen)
if (!unikate.contains(s))
unikate.add(s);
System.out.println("Liste der eindeutigen Namen:");
for(String s : unikate)
System.out.println(s);
}
}
Im Programm werden zunächst einige (nicht unbedingt eindeutige) Namen per add() ohne bzw. mit
Angabe der Indexposition in die Liste eingefügt. Dann darf der Benutzer für beliebig viele Namen
die Indexposition in der Liste abfragen, wobei die Methode indexOf() zum Einsatz kommt. Die zu
untersuchenden Zeichenfolgen werden mit der Methode nextLine() der Klasse Scanner aus dem
Paket java.util (vgl. Abschnitt 3.4.1) eingelesen. Diese Methode liest eine mit Enter quittierte Zei-
le von der Konsole in ein String-Objekt ein. Schließich wird eine zweite Liste mit den eindeutigen
Namen erstellt, wobei zur Existenzprüfung die Collection<E> - Methode contains() zum Einsatz
kommt. Die for-Schleife für Arrays und Kollektionen (vgl. Abschnitt 3.7.3.1) kann verwendet wer-
den, weil eine List<E> - Implementation auch die von Collection<E> erweiterte Schnittstelle
Iterable<E> erfüllt und daher ein Iterieren über ihre Elemente ermöglicht (siehe Abschnitt 10.5).
Das Protokoll eines Programmablaufs:
Namensliste:
Otto
Rita
Otto
Leo

Zu suchender Name oder leere Eingabe zum Beenden: Leo


Position: 3
Zu suchender Name oder leere Eingabe zum Beenden: Karla
Position: -1
Zu suchender Name oder leere Eingabe zum Beenden

Liste der eindeutigen Namen:


Otto
Rita
Leo

Weil die Klasse ArrayList<E> einen Konstruktor mit Kollektionstyp


public ArrayList(Collection<? extends E> c)
besitzt, kann die im Beispiel verwendete, umständliche Elementbestückung
List<String> namen = new ArrayList<>();
namen.add("Rita"); namen.add("Otto"); namen.add("Leo");
unter Verwendung der List<E> - Methode of() vereinfacht werden:
List<String> namen = new ArrayList<>(List.of("Rita", "Otto", "Leo"));
Aus der von of() gelieferten unveränderlichen Liste (vgl. Abschnitt 10.4.1) resultiert ein veränderli-
ches ArrayList<String> - Objekt.
492 Kapitel 10 Java Collections Framework

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.

10.4.3.1 Array als Hintergrundspeicher


Die Klasse ArrayList<E> arbeitet intern mit einem Array zum Speichern der Elemente und bietet
daher einen schnellen wahlfreien Zugriff (engl.: random access). Auch das Anhängen neuer Ele-
mente am Ende der Liste verläuft flott, wenn nicht gerade die Kapazität des Arrays erschöpft ist.
Dann wird es erforderlich, einen größeren Array zu erzeugen und alle Elemente dorthin zu kopie-
ren. Beim Einfügen bzw. Löschen von inneren Elementen müssen die neuen bzw. früheren rechten
Nachbarn zeitaufwändig nach rechts bzw. links verschoben werden.
Listenklassen mit schnellem wahlfreiem Zugriff sollten das Marker-Interface RandomAccess im-
plementieren. Unter dieser Voraussetzung lässt sich mit Hilfe des instanceof-Operators für eine
Liste prüfen, ob ein schneller wahlfreier Zugriff möglich ist, z. B.:
List<String> ls = new LinkedList<String>(List.of("Otto", "Luise", "Rainer"));
if (!(ls instanceof RandomAccess))
ls = new ArrayList<>(ls);
Im Beispiel wird die bei Evans & Flanagan (2015, S. 248) gefundene Idee realisiert, bei Bedarf eine
Liste mit wahlfreiem Zugang als Kopie zu erstellen. Die Klasse ArrayList<E> bietet dazu eine
passende Konstruktor-Überladung:
public ArrayList(Collection<? extends E> c)
Während ein ArrayList<E> - Objekt den intern zum Speichern der Elemente verwendeten Array
bei Bedarf automatisch vergrößert, existiert kein Automatismus zum Verkleinern dieses Arrays
nach dem Löschen von Elementen. Dazu ist ein Aufruf der Methode trimToSize() erforderlich:
public void trimToSize()
Die ebenfalls Array-basierte Klasse Vector<E> war von Beginn an im Java-API enthalten, wurde
zwar an das später entwickelte Java Collections Framework angepasst, steht aber trotzdem nicht
mehr im besten Ruf. Sie enthält neben den empfohlenen Methoden aus dem Interface List<E> auch
noch veraltete Methoden, die nicht mehr verwendet werden sollten, weil sie den Wechsel zu einer
alternativen Listenverwaltungsklasse verhindern, also die Flexibilität und Wiederverwendung der
Software erschweren.
Ein weiterer wesentlicher Unterschied zur Klasse ArrayList<E> besteht darin, dass Vector<E>
thread-sicher realisiert wurde, sodass ein Container-Objekt ohne Risiko simultan durch mehrere
Threads benutzt werden kann.1 Was das genau bedeutet, werden Sie im Kapitel 15 über das Mul-
tithreading (die nebenläufige Programmierung) erfahren. Allerdings ist die Sicherheit nicht kosten-
los zu haben, sodass die Klasse ArrayList<E> performanter arbeitet und zu bevorzugen ist, wenn
kein simultaner Zugriff durch mehrere Threads auftreten kann. Wenn Sie die Klasse ArrayList<E>
in einer Multithreading - Anwendung einsetzen, dann müssen Sie allerdings selbst für die Synchro-
nisation der Threads sorgen. Weil eventuell der eine oder andere Leser schon davon profitieren

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.

10.4.3.2 Verkette Objekte


Die Klasse LinkedList<E> arbeitet im Unterschied zur Klasse ArrayList<E> intern mit einer
doppelt verketteten Liste bestehend aus selbständigen (auch als Knoten bezeichneten) Objekten,
die jeweils ihren Nachfolger und ihren Vorgänger kennen, z. B.:
LinkedList<String> - Objekt

Adresse des ersten Elements

Adresse des letzten Elements

Listenelement mit: Listenelement mit: Listenelement mit:


Inhalt (String-Adr.) Inhalt (String-Adr.) Inhalt (String-Adr.)

Adresse des Nachfolgers Adresse des Nachfolgers Adresse des Nachfolgers

Adresse des Vorgängers Adresse des Vorgängers Adresse des Vorgängers

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).

10.4.4 Leistungsunterschiede und Einsatzempfehlungen


Bei der Klasse LinkedList<E> machen es die List<E> - Methoden mit Index-Parameter (z. B.
get(), remove()) erforderlich, sich vom Startpunkt (Index  halbe Länge) oder Endpunkt (Index >
halbe Länge) ausgehend bis zur gesuchten Position vorzuarbeiten, wobei diese aufwändige Proze-
dur bei jedem Methodenaufruf neu startet. Genügt ein sequentieller Zugriff, dann sollte bei einer
verlinkten Liste unbedingt ein Iterator-Objekt verwendet werden, um die Elemente nacheinander
aufzusuchen (siehe Abschnitt 10.5). Wird ein wahlfreier Zugriff benötigt, ist eine Array-basierte
Klasse zu bevorzugen.
Bei den Klassen ArrayList<E> und Vector<E> mit Array-Unterbau entsteht ein großer Aufwand,
wenn ein inneres Element eingefügt oder entfernt werden muss.
Man kann je nach Einsatzschwerpunkt und benötigter Thread-Sicherheit zwischen den Listenver-
waltungsklassen aus dem Java Collections Framework wählen und unproblematisch wechseln,
wenn man als Datentyp für Variablen und Parameter das Interface List<E> verwendet.
Im folgenden Programm
import java.util.*;

class Listen {
static final int ANZ = 40_000;

static void testList(List<String> plis) {


StringBuilder sb = new StringBuilder();
Random ran = new Random();

// 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));

// Abrufen per Index-Zugriff


start = System.currentTimeMillis();
for (int i = 0; i < ANZ; i++)
plis.get(ran.nextInt(ANZ));
System.out.printf(" %-50s %7d\n", "Zeit zum Abrufen:",
(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

// Einfügen am einer zufälligen Position


start = System.currentTimeMillis();
for (int i = 0; i < ANZ; i++)
plis.add(ran.nextInt(ANZ), "neu");
System.out.printf(" %-50s %7d\n", "Zeit zum Einfügen an einer zufälligen Position:",
(System.currentTimeMillis() - start));

// 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));
}

public static void main(String[] args) {


System.out.println("Warmlaufen:");
testList(new ArrayList<String>());
testList(new Vector<String>());
testList(new LinkedList<String>());
System.out.println("\nVerwertbare Testergebnisse:");
testList(new ArrayList<String>());
testList(new Vector<String>());
testList(new LinkedList<String>());
}
}

mit den Aufgaben


• eine Liste mit 40.000 Zeichenketten füllen
• aus der Liste 40.000 Elemente mit zufällig bestimmter Indexposition abrufen
• 40.000 neue Elemente am Anfang der Liste einfügen
• 40.000 neue Elemente an einer zufällig gewählten Position in eine Liste mit initial 80.000
Elementen einfügen
• 40.000 Elemente einzeln am Anfang einer Liste mit 120.000 Elementen löschen
zeigen die drei Klassen ArrayList<String>, Vector<String> und LinkedList<String> die folgen-
den Leistungen:1
Kollektionsklasse: class java.util.ArrayList
Zeit zum Füllen: 7
Zeit zum Abrufen: 0
Zeit zum Einfügen am Anfang: 543
Zeit zum Einfügen an einer zufälligen Position: 903
Zeit zum Löschen am Anfang: 1200

Kollektionsklasse: class java.util.Vector


Zeit zum Füllen: 5
Zeit zum Abrufen: 0
Zeit zum Einfügen am Anfang: 536
Zeit zum Einfügen an einer zufälligen Position: 895
Zeit zum Löschen am Anfang: 1253

Kollektionsklasse: class java.util.LinkedList


Zeit zum Füllen: 6
Zeit zum Abrufen: 1613
Zeit zum Einfügen am Anfang: 2
Zeit zum Einfügen an einer zufälligen Position: 5443
Zeit zum Löschen am Anfang: 1

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);
}
}

Die Iteratoren vieler Klassen (z. B. ArrayList<E>, LinkedList<E>, Vector<E>, HashSet<E>,


TreeSet<E>, HashMap<K,V>, TreeMap<K,V>) sind fail-fast: Ist die Kollektion nach der Erstel-
lung des Iterators auf andere Weise als durch die remove() - Methode des Iterators strukturell geän-
dert worden, dann werfen die kritischen Methoden des Iterators eine
ConcurrentModificationException, um erratisches Verhalten zu verhindern. Allerdings gelingt
die Detektion nicht zuverlässig, sodass die Korrektheit eines Programms nicht von den fail-fast -
Bemühungen des Iterators abhängen darf.
Iteratoren haben ihren Einsatzschwerpunkt bei verketteten Listen (siehe Abschnitt 10.4.3), wo sie
im Vergleich zum zeitaufwändigen Indexzugriff für einen Performanzschub sorgen. Sie sind aber
498 Kapitel 10 Java Collections Framework

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).

10.6.1 Das Interface Set<E>


Das von Collection<E> abstammende Interface Set<E> verlangt keine zusätzlichen Instanzmetho-
den. Die beiden Schnittstellen unterscheiden sich nur hinsichtlich der Namen und hinsichtlich der
Dokumentationskommentare, die bei einigen Methoden unterschiedliche Verhaltensanforderungen
(„Verträge“) formulieren, z. B.:
• public boolean add(E e) (optionale Operation)
Das Parameterelement e wird in die Menge aufgenommen, wenn dort noch kein Element e2
vorhanden ist, sodass der folgende Aufruf der statischen equals() - Methode der Klasse Ob-
jects den Wert true besitzt:
Objects.equals(e, e2)
• public boolean addAll(Collection<? extends E> collection) (optionale Operation)
Die Elemente der übergebenen Kollektion werden in die Menge aufgenommen, falls sie dort
noch nicht vorhanden sind (siehe add() zum Existenzkriterium). Ihr Typ muss mit dem
Elementtyp der angesprochenen Mengenklasse übereinstimmen oder diesen Typ spezialisie-
ren. Nach einem erfolgreichen Methodenaufruf enthält die Kollektion die Vereinigung der
beiden Mengen.
Bei den folgenden Methoden sind die Verträge im Vergleich zur Schnittstelle Collection<E> un-
verändert, doch lassen sich ihre Effekte mengentheoretisch interpretieren:
• public boolean removeAll(Collection<?> collection) (optionale Operation)
Die Elemente der Parameterkollektion werden ggf. aus der angesprochenen Kollektion ent-
fernt, sodass man nach einem erfolgreichen Methodenaufruf die Differenz der beiden Men-
gen bzw. Kollektionen erhält.
• public boolean retainAll(Collection<?> collection) (optionale Operation)
Aus der angesprochenen Kollektion werden alle Elemente entfernt, die nicht zur Parameter-
kollektion gehören, sodass man nach einem erfolgreichen Methodenaufruf den Durch-
schnitt der beiden Mengen bzw. Kollektionen erhält.
Bei der mengentheoretischen Interpretation der Methoden addAll(), removeAll() und retainAll()
ist zu beachten, dass als Parameterkollektionen keine Mengen vorgeschrieben sind.
Die Set<E> - Methoden add(), addAll(), removeAll() und retainAll() informieren mit ihrem
boolean-Rückgabewert darüber, ob die angesprochene Menge durch den Aufruf verändert wurde.
Seit Java 9 besitzt die Schnittstelle Set<E> eine statische Fabrikmethode namens of(), mit der sich
auf einfache Weise unveränderliche Set<E> - Objekte erstellen lassen, z. B.:1

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);
}
}

Aus Performanzgründen sind die Klassen HashSet<E>, LinkedHashSet<E> und TreeSet<E>


nicht thread-sicher implementiert. Allerdings liefert die Klasse Collections über die statische Me-
Abschnitt 10.6 Mengen 501

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.2 Leistungsvorteil bei der Existenzprüfung


Um den Leistungsunterschied zwischen List<E> - und Set<E> - Implementierungen bei der Exis-
tenzprüfung zu untersuchen, wurden in einem Testprogramm folgende Aufgaben gestellt:
• Eine Kollektion mit 20.000 String-Objekten füllen
• Für 20.000 neue String-Objekte prüfen, ob sie bereits in der Kollektion vorhanden sind
Dabei zeigten die Klassen ArrayList<String>, HashSet<String> und TreeSet<String> die fol-
genden Leistungen:1
Kollektionsklasse: class java.util.ArrayList
Zeit zum Füllen: 6
Zeit für die Existenzprüfungen: 1666

Kollektionsklasse: class java.util.HashSet


Zeit zum Füllen: 5
Zeit für die Existenzprüfungen: 4

Kollektionsklasse: class java.util.TreeSet


Zeit zum Füllen: 21
Zeit für die Existenzprüfungen: 10
Bei der Existenzprüfung sind die beiden Set<E> - Implementationen deutlich überlegen.

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

Adresse Element Adresse


Listenanfang Nachfolger

Adresse Element Adresse Element Adresse


Listenanfang Nachfolger Nachfolger

Adresse Element Adresse


Listenanfang Nachfolger

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).

10.6.4 Balancierte Binärbäume


Existiert über den Elementen einer Menge eine vollständige Ordnung (z. B. bei Zeichenketten),
dann kann man über einen sogenannten Binärbaum die Elemente im sortierten Zustand halten, ohne
den Aufwand bei den zentralen Mengenverwaltungsmethoden (z. B. add(), contains() und re-
move()) zu groß werden zu lassen (siehe Leistungsvergleich im Abschnitt 10.6.2).
In einem Binärbaum hat jeder Knoten maximal zwei direkte Nachfolger, wobei der linke Nachfol-
ger einen kleineren und der rechte Nachfolger einen höheren Rang hat, was die folgende Abbildung
für Zeichenketten illustriert:
504 Kapitel 10 Java Collections Framework

Fritz

Berta Ludwig

Anton Charly Lars Norbert

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

0 2000 4000 6000 8000 10000

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.

10.6.5 Interfaces für geordnete Mengen


Die Klasse TreeSet<E> implementiert über das Interface Set<E> hinaus auch das Interface
SortedSet<E> mit Methoden für geordnete Mengen und das mit Java 6 als SortedSet<E> - Erwei-
terung und designierter Nachfolger hinzugekommene Interface NavigableSet<E>. Hier sind die
drei Interfaces und Ihre Beziehungen zu sehen:
Set<E>

SortedSet<E>

NavigableSet<E>

Die von der Schnittstelle SortedSet<E> geforderte Methode


public Comparator<? super E> comparator()
lässt zwei Möglichkeiten, die Ordnung der von einer implementierenden Klasse (z. B. TreeSet<E>)
verwalteten Elemente zu begründen:
• Der Elementtyp E erfüllt das Interface Comparable<E>, besitzt also eine Instanzmethode
public int compareTo(E e)
und somit eine natürliche Ordnung.
• Man übergibt dem Konstruktor ein Objekt, das die Schnittstelle Comparator<? super E>
erfüllt und folglich für den Typ E oder für eine Basisklasse B eine Vergleichsmethode mit
dem folgenden Definitionskopf bietet:
public int compare(E e1, E e2) bzw. public int compare(B e1, B e2)
Die Methoden compareTo() bzw. compare() müssen konsistent mit der equals() - Methode des
Elementtyps sein, denn:
• Im Interface Set<E> basieren die Verträge vieler Methoden (z.B. contains()) auf der
equals() - Methode (siehe Abschnitt 10.6.1).
• Eine SortedSet<E> - Implementation benutzt für alle Elementvergleiche und insbesondere
auch für Übereinstimmungsprüfungen die Methoden compareTo() bzw. compare().
Abschnitt 10.6 Mengen 507

Konkret heißt das z. B. für die Comparator<E> - Methode compare():1


The ordering imposed by a comparator c on a set of elements S is said to be consistent with
equals if and only if c.compare(e1, e2)==0 has the same boolean value as e1.equals(e2) for
every e1 and e2 in S.
Das Interface SortedSet<E> fordert von implementierenden Klassen u. a. die folgenden Methoden:
• public Comparator<? super E> comparator()
Es wird ein Comparator<? super E> - Objekt geliefert oder null. In diesem Fall muss E
das Interface Comparable<E> implementieren.
• public E first()
Die Methode liefert das erste (kleinste) Element in der sortierten Menge.
• public E last()
Die Methode liefert das letzte (größte) Element in der sortierten Menge.
• public SortedSet<E> headSet(E obereSchranke)
Man erhält als sogenannte Sicht (engl.: View) auf die Teilmenge mit allen Elementen der
angesprochenen Kollektion, die kleiner als die obere Schranke sind, ein Objekt aus einer
Klasse, welche das Interface SortedSet<E> erfüllt. Alle Methoden des View-Objekts wir-
ken sich auf die Originalkollektion aus, sodass man z. B. mit der Methode clear() die kom-
plette headSet() - Teilmenge löschen kann:
Quellcode Ausgabe
import java.util.*; [a, b, c, d]
class Prog {
public static void main(String[] args) { [a, b]
SortedSet<String> ts=new TreeSet<>(Set.of("a","b","c","d"));
System.out.println(ts); [c, d]
SortedSet<String> sos = ts.headSet("c");
System.out.println(sos);
sos.clear();
System.out.println(ts);
}
}

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);
}
}

• public SortedSet<E> subSet(E untereSchranke, E obereSchranke)


Man erhält eine Sicht auf einen Bereich der angesprochenen Kollektion beginnend mit der
unteren Schranke (inklusive) und endend mit der oberen Schranke (exklusive). Alle Metho-
den des View-Objekts wirken sich auf die Originalkollektion aus.
Im Definitionskopf einer das Interface SortedSet<E> implementierenden Klasse (z. B.
TreeSet<E>) kann und sollte darauf verzichtet werden, den Typformalparamater auf die Schnitt-
stelle Comparable<E> zu restringieren, weil die von SortedSet<E> geforderte Methode compar-
ator() (siehe oben) die Vergleichbarkeit der Elemente sicherstellt.
Im folgenden Beispielprogramm verwendet das TreeSet<String> - Objekt tss die natürliche Ord-
nung der Klasse String, während im TreeSet<String> - Objekt tssc ein Objekt der Klasse
CompaS, die die Schnittstelle Comparator<String> erfüllt, dafür sorgt, dass Otto immer vorne
steht:
Quellcode Ausgabe
// Datei CompaS [Ludwig, Otto, Werner]
import java.util.*; [Otto, Ludwig, Werner]

class CompaS implements Comparator<String> {


public int compare(String s1, String s2) {
if (s1.equals("Otto"))
return -1;
if (s2.equals("Otto"))
return 1;
return s1.compareTo(s2);
}
}

// 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);

TreeSet<String> tssc = new TreeSet<>(new CompaS());


tssc.addAll(tss);
System.out.println(tssc);
}
}

Die bereits im Interface Collection<E> vorhandenen Methoden add(E element) und


addAll(Collection<? extends E> coll) müssen von einer SortedSet<E> - Implementation so reali-
siert werden, dass ...
Abschnitt 10.6 Mengen 509

• keine Dubletten entstehen,


• und die Sortierung erhalten bleibt bzw. hergestellt wird.
Damit kann man einem Objekt der Klasse TreeSet<String> z. B. viel Arbeit bei der Aufbereitung
einer Liste von Wörtern übertragen. Im folgenden, auf einer Idee von Eck (2021, S. 508) basieren-
den Programm
Quellcode Ausgabe
import java.util.*; Obst:
Zitrone
class Prog { Apfel
public static void main(String[] args) { Birne
List<String> obst = List.of("Zitrone", "Apfel", "Birne", "Apfel"); Apfel
System.out.println("Obst:");
for (String s : obst) Sorten:
System.out.println(" " + s); Apfel
Birne
List<String> sortSorten = new ArrayList<>(new TreeSet<>(obst)); Zitrone
System.out.println("\nSorten:");
for (String s : sortSorten)
System.out.println(" " + s);
}
}

wird eine ungeordnete Ausgangsliste mit Dubletten


List<String> obst = List.of("Zitrone", "Apfel", "Birne", "Apfel");
durch eine einzige Anweisung in eine geordnete Liste ohne Dubletten überführt:
List<String> sortSorten = new ArrayList<>(new TreeSet<>(obst));
Der TreeSet<String> - Konstruktor erledigt die Aufbereitung und liefert sein Ergebnis an den
ArrayList<String> - Konstruktor.
Das seit Java 6 vorhandene Interface NavigableSet<E> erweitert das Interface SortedSet<E>.
U. a. werden zusätzlich die folgenden Methoden verlangt:
• public E pollFirst()
Das erste (kleinste) Element in der navigierbaren Menge wird als Rückgabe geliefert und
gelöscht.
• public E pollLast()
Das letzte (größte) Element in der navigierbaren Menge wird als Rückgabe geliefert und ge-
löscht.
• public E ceiling(E argument)
Man erhält als Rückgabe das kleinste Element in der navigierbaren Menge, das mindestens
ebenso groß ist wie das Argument.
• public E floor(E argument)
Man erhält als Rückgabe das größte Element in der navigierbaren Menge, welches das Ar-
gument nicht übertrifft.
• public E higher(E argument)
Man erhält als Rückgabe das kleinste Element in der navigierbaren Menge, welches das Ar-
gument übertrifft.
• public E lower(E argument)
Man erhält als Rückgabe das größte Element in der navigierbaren Menge, welches dem Ar-
gument unterlegen ist.
510 Kapitel 10 Java Collections Framework

• public Iterator<E> descendingIterator()


Diese Methode liefert ein Iterator-Objekt, das ein sequentielles Aufsuchen der Kollektions-
elemente in umgekehrter Reihenfolge unterstützt (siehe Abschnitt 10.5).
Existiert kein passendes Element, liefern die Methoden pollFirst(), pollLast(), ceiling(), floor(),
higher() und lower() die Rückgabe null. Im folgenden Programm werden die gerade genannten
Methoden vorgeführt:
Quellcode Ausgabe
import java.util.*; Ceiling of e: k
class Prog { Floor of b: a
public static void main(String[] args) { Higher than a: c
NavigableSet<String> tss = new TreeSet<>( Lower than b: a
Set.of("a", "c", "k")); First: a, Last: k
System.out.println("Ceiling of e: " + tss.ceiling("e")); Remaining: [c]
System.out.println("Floor of b: " + tss.floor("b"));
System.out.println("Higher than a: " + tss.higher("a"));
System.out.println("Lower than b: " + tss.lower("b"));
System.out.println("First: " + tss.pollFirst() +
", Last: " + tss.pollLast());
System.out.println("Remaining: " + 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.

10.7.1 Das Interface Map<K,V>


Im Interface Map<K,V> werden zwei Typformalparameter (für Key und Value) verwendet, und
Map<K,V> stammt (im Unterschied zu List<E> und Set<E>) nicht von Collection<E> ab. An-
schließend wird eine Auswahl von den zahlreichen Methoden der Schnittstelle Map<K,V> vorge-
stellt. Dabei werden Methoden mit funktionalen Schnittstellen als Parametern weggelassen, weil
wir uns noch nicht mit der funktionalen Programmierung beschäftigt haben (siehe Kapitel 12).
Das Interface Map<K,V> verlangt von einer implementierenden Klasse u. a. die folgenden In-
stanzmethoden zur Aufnahme oder Veränderung von (Schlüssel-Wert) - Paaren:
• public V put(K key, V value) (optionale Operation)
Wenn der Schlüssel noch nicht existiert, wird ein neues (Schlüssel-Wert) - Paar angelegt.
Anderenfalls wird der alte Wert überschrieben. Um ein neues Paar mit noch unbekanntem
Wert anzulegen oder einen vorhandenen Wert zu löschen, kann man das Referenzliteral null
als Wert angeben. Als Rückgabe liefert die Methode den aktuellen Wert.
• public void putAll (Map<? extends K,? extends V> map) (optionale Operation)
Beim Import der (Schlüssel-Wert) - Paare aus der Parameterkollektion werden ggf. für vor-
handene Schlüssel die Werte geändert. Durch die gebundene Wildcard-Typdeklarationen
(siehe Abschnitt 8.3) wird für die Kollektion mit den Aufnahmekandidaten gefordert, den-
selben K- bzw. V-Typ wie die im putAll() - Aufruf angesprochene Abbildung zu verwen-
den oder eine Spezialisierung (Ableitung).
512 Kapitel 10 Java Collections Framework

• public default V replace(K key, V value)


Ist der Schlüssel vorhanden, wird sein Wert neu festgelegt. Anderenfalls passiert nichts (im
Unterschied zur Methode put(), die eine vorhandene Assoziation ändert oder eine neue ein-
fügt).
Zum Entfernen von Assoziationen schreibt das Interface Map<K,V> die folgenden Instanzmetho-
den vor:
• public void clear() (optionale Operation)
Mit dieser Methode werden alle Assoziationen aus der Abbildung entfernt.
• public V remove(Object key) (optionale Operation)
Existiert eine Assoziation mit dem angegebenen Schlüssel, dann wird diese Assoziation aus
der Abbildung entfernt und ihr ehemaliger Wert an den Aufrufer geliefert. Anderenfalls er-
hält der Aufrufer die Rückgabe null. Die etwas verblüffende Verwendung des Parameter-
typs Object wird gleich diskutiert.
Für Existenzprüfungen oder Wertabfragen sind die folgenden Methoden zuständig:
• public boolean containsKey(Object key)
Die Methode liefert true zurück, wenn der angegebene Schlüssel in der Abbildung vorhan-
den ist, sonst false.
• public boolean containsValue(Object value)
Die Methode liefert true zurück, wenn der angegebene Wert in der Abbildung vorhanden ist
(eventuell auch mehrfach), sonst false. Eine Abbildungsklasse ist für die schnelle Schlüssel-
suche konstruiert und muss bei einer Wertsuche zeitaufwändig nacheinander alle Elemente
bis zum ersten Treffer inspizieren.
• public V get(Object key)
Man erhält den zum angegebenen Schlüssel gehörigen Wert oder null, falls der Schlüssel
nicht vorhanden ist.
Die Methoden get() und put() werden am häufigsten benötigt.
Warum bei den Methoden remove(), containsKey(), containsValue() und get() kein Typformalpa-
rameter (also K bzw. V) sondern die Klasse Object als Parametertyp zum Einsatz kommt, er-
schließt sich nicht sofort. Eine überzeugende Erklärung hat Luke Hutteman auf Stack Overflow
präsentiert:1 Auf diese Weise können die Methoden auch dann verwendet werden, wenn bei einer
Abbildungsreferenzvariablen unbeschränkte Wildcard-Datentypen verwendet werden. Die im fol-
genden Beispiel verwendete Map<K,V> - Implementation HashMap<K,V> wird im Abschnitt
10.7.2 vorgestellt:

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:

Methode Sicht auf die ... Implementierte Schnittstelle


keySet() Menge der Schlüssel Set<K>
values() Kollektion der Werte Collection<V>
entrySet() Menge der Assoziationen Set<Map.Entry<K,V>>
Dabei bestehen die folgenden Gemeinsamkeiten:
• Über eine Sicht vorgenommene Veränderungen wirken sich unmittelbar auf die Abbildung
aus und umgekehrt.
• Über eine Sicht kann man Assoziationen aus der Abbildung entfernen, aber keine Assoziati-
onen aufnehmen.
• Während das Interface Map<K,V> im Unterschied zum Interface Collection<E> keine Me-
thode iterator() besitzt, die einen Iterator zum sequentiellen Aufsuchen aller Kollektions-
elemente liefert, ist über die Sichten genau diese Funktionalität realisierbar. Ein Iterator
zeigt ein undefiniertes Verhalten, wenn die Abbildung nach seiner Erstellung auf andere
Weise also durch seine eigene remove() - Methode geändert wird.
Es folgen einige Details und Anwendungsbeispiele zu den drei View-Produzenten:
• public Set<K> keySet()
Diese Methode liefert ein Objekt, das die Schnittstelle Set<K> erfüllt (vgl. Abschnitt 10.6)
und als Sicht auf der Menge aller Schlüssel in der angesprochenen Abbildung operiert. Man
kann z. B. mit der Set<K> - Methode clear() sämtliche Schlüssel und damit sämtliche As-
soziationen aus der Abbildung entfernen:
Quellcode Ausgabe
import java.util.*; {1=A, 2=B, 3=C}
class Prog { {}
public static void main(String[] args) {
Map<Integer,String> mis = new HashMap<>();
mis.put(1, "A"); mis.put(2, "B"); mis.put(3, "C");
System.out.println(mis);
mis.keySet().clear();
System.out.println(mis);
}
}

Die im Beispiel verwendete Map<K,V> - Implementation HashMap<K,V> wird im Ab-


schnitt 10.7.2 vorgestellt.
514 Kapitel 10 Java Collections Framework

• public Collection<V> values()


Diese Methode liefert ein Objekt, das die Schnittstelle Collection<V> - erfüllt (vgl. Ab-
schnitt 10.3) und als Sicht auf der Menge aller Werte in der angesprochenen Abbildung ope-
riert. Man kann z. B. mit der Collection<V> - Methode remove() das erste (Schlüssel-Wert)
- Paar mit einem bestimmten Wert löschen:
Quellcode Ausgabe
import java.util.*; {1=A, 2=C, 3=C}
class Prog { {1=A, 3=C}
public static void main(String[] args) {
Map<Integer,String> mis = new HashMap<>();
mis.put(1, "A"); mis.put(3, "C"); mis.put(2, "C");
System.out.println(mis);
Collection<String> cs = mis.values();
cs.remove("C");
System.out.println(mis);
}
}

Die im Beispiel verwendete Map<K,V> - Implementation HashMap<K,V> wird im Ab-


schnitt 10.7.2 vorgestellt.
• public Set<Map.Entry<K,V>> entrySet()
Diese Methode liefert ein Objekt, das die Schnittstelle Set<Map.Entry<K,V>> - erfüllt
(vgl. Abschnitt 10.6) und als Sicht auf der Menge aller (Schlüssel-Wert) - Paare aus der an-
gesprochenen Abbildung operiert. Die Kompetenzen eines Elements der Ergebnismenge
werden durch das Interface Map.Entry<K,V> beschrieben, das als (implizit statisches und
öffentliches) Mitglieds-Interface innerhalb von Map<K,V> definiert ist:1
public interface Map<K,V> {
. . .
interface Entry<K,V> {
K getKey();
V getValue();
V setValue(V value);
boolean equals(Object o);
int hashCode();
}
. . .
}
In der parametrisierten Klasse des von entrySet() gelieferten Objekts ist der Typformalpa-
rameter durch eine parametrisierte Klasse konkretisiert, die das Interface Map.Entry<K,V>
implementiert. Ihre Objekte repräsentieren jeweils eine Assoziation. Weil der Container und
die Assoziation generisch definiert sind, liegt eine Verschachtelung von generischen Typde-
finitionen vor.
Mit den folgenden Methoden informiert eine Abbildung über die Anzahl ihrer Elemente:
• public boolean isEmpty()
Mit dieser Methode erfährt man, ob die angesprochene Abbildung leer ist.
• public int size()
Liefert die Anzahl der Elemente in der 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");
}
}

Die auskommentierte Anweisung hätte einen Laufzeitfehler vom Typ UnsupportedOperation-


Exception zur Folge, weil die of() - Produkte unveränderlich sind.
Den Aufruf der statischen, generischen Methode of() richtet man an den Rohtyp der generischen
Schnittstelle Map<K,V>. Wegen der Typinferenz-Fähigkeiten des Compilers ist es nicht erforder-
lich, beim Aufruf der generischen Methode die Typaktualparameter anzugeben:
Map<Integer,String> mis = Map.<Integer,String>of(1, "a", 2, "b", 3, "c");
An die statische Map<K,V> - Methode of() übergibt man jeweils einen K- und einen V-
Aktualparameter, um ein (Schlüssel-Wert)-Paar einzufügen. An die ebenfalls in Java 9 eingeführte
statische Map<K,V> - Methode ofEntries(), die gleichfalls eine unveränderliche Abbildung
erstellt, übergibt man Objekte des innerhalb von Map<K,V> definierten Typs Entry<K,V>. Im
folgenden Beispiel werden diese Objekte von der statischen und generischen Methode entry() aus
der Schnittstelle Map<K,V> erstellt. Die Methode wird statisch in die Quellcodedatei importiert
(siehe Abschnitt 6.1.2.2 zum statischen Import):
Quellcode Ausgabe
import java.util.*; {1=a, 3=c, 2=b}
import static java.util.Map.entry;

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

10.7.2 Die Klasse HashMap<K,V>


Über einen Mengenverwaltungscontainer (z. B. aus der Klasse HashSet<E>) kann man für Objekte
eines Typs festhalten, ob sie sich in einer Menge befinden oder nicht. Ein einfaches Beispiel ist et-
wa die Menge aller Zeichen (Character-Objekte), die in einem Text auftreten. Mit den im aktuellen
Abschnitt 10.7 behandelten Abbildungsklassen lassen sich zu jedem Objekt im Container noch zu-
sätzliche Informationen aufbewahren. Im gerade erwähnten Beispiel könnte man zu jedem Zeichen
noch die Häufigkeit des Auftretens speichern. Für den Text "Otto spielt Lotto" resultiert die folgen-
de Tabelle mit den Zeichen und ihren Auftretenshäufigkeiten:
p --> 1
s --> 1
t --> 5
e --> 1
i --> 1
l --> 1
L --> 1
O --> 1
o --> 3
Durch die Pfeilnotation wird betont, dass es sich tatsächlich um eine Abbildung im mathematischen
Sinn handelt (von einer Teilmenge der Buchstaben in die Menge der natürlichen Zahlen).
Die folgende statische Methode countLetters() liefert ein HashMap<Character, Mint> - Ob-
jekt mit Paaren aus einem Schlüssel vom Typ Character und einem Wert vom Typ Mint (eine
selbst entworfene int-Hüllenklasse, vgl. Übungsaufgabe im Abschnitt 5.3). Als Rückgabetyp wird
jedoch nicht die parametrisierte Klasse angegeben, sondern das parametrisierte Interface
Map<Character, Mint>, damit der tatsächliche Typ später als Implementierungsdetail geändert
werden kann:
public static Map<Character,Mint> countLetters(String text) {
Map<Character,Mint> fred = new HashMap<>();
Mint temp;
for (int i = 0; i < text.length(); i++) {
char pc = text.charAt(i);
if (Character.isLetter(pc)) {
Character c = pc;
if (fred.containsKey(c)) {
temp = fred.get(c);
temp.val++;
fred.replace(c, temp);
} else
fred.put(c, new Mint(1));
}
}
return fred;
}

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

Ein HashMap<K,V> - Objekt kann so skizziert werden:

Adresse Schlüssel Wert Adresse


Listenanfang Nachfolger

Adresse Schlüssel Wert Adresse Schlüssel Wert Adresse


Listenanfang Nachfolger Nachfolger

Adresse Schlüssel Wert Adresse


Listenanfang Nachfolger

Um die (Schlüssel-Wert) - Paare in einem HashMap<K,V> - Container sukzessive anzusprechen,


kann man z. B. über die Methode entrySet() ein Mengenverwaltungsobjekt mit den (Schlüssel-
Wert) - Paaren als Elementen anfordern und dessen Iterator (siehe Abschnitt 10.5) benutzen. Dabei
zeigt sich erwartungsgemäß eine zufällig wirkende Reihenfolge.
Mit der Klasse LinkedHashMap<K,V> steht eine HashMap<K,V> - Ableitung zur Verfügung,
deren Objekte sich die Einfügereihenfolge der Elemente merken. Dies wird durch den Zusatzauf-
wand 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 HashMap<E> - Variante, weil die leeren Buckets nicht aufgesucht werden müssen
(Naftalin & Wadler 2007, S. 181).

public class HashSet<E> extends AbstractSet<E>


implements Set<E>, Cloneable, java.io.Serializable {

@java.io.Serial
static final long serialVersionUID = -5024744406713321676L;

private transient HashMap<E,Object> map;

// Dummy value to associate with an Object in the backing Map


private static final Object PRESENT = new Object();

/**
* 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

10.7.3 Interfaces für Abbildungen mit geordneten Schlüsseltypen


Analog zu den Verhältnissen bei den Schnittstellen Set<E>, SortedSet<E> und NavigableSet<E>
zur Mengenverwaltung (siehe Abschnitt 10.6) existieren für Abbildungen mit geordneten Schlüssel-
typen zum Interface Map<K,V> die folgenden Erweiterungen:
• das Interface SortedMap<K,V>
• das mit Java 6 als designierter Nachfolger hinzu gekommene Interface
NavigableMap<K,V>
Hier sind die drei Interfaces und Ihre Beziehungen zu sehen:
Map<K,V>

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

• public SortedMap<K,V> headMap(K obereSchranke)


Man erhält ein Objekt aus einer Klasse, welche das Interface SortedMap<K,V> erfüllt, und
als Sicht (engl.: View) auf der Teilmenge der (Schlüssel-Wert) - Paare aus der angesproche-
nen Abbildung mit einem Schlüssel unterhalb der oberen Schranke operiert. Alle Methoden
des View-Objekts wirken sich auf die Originalkollektion aus, sodass man z. B. mit der Me-
thode clear() die komplette headMap() - Teilmenge löschen kann.
• public SortedMap<K,V> tailMap(K untereSchranke)
Die Methoden des resultierenden View-Objekts wirken auf die Teilmenge der (Schlüssel-
Wert) - Paare aus der angesprochenen Abbildung mit einem Schlüssel ab der unteren
Schranke (inklusive).
• public SortedMap<K,V> subMap(K untereSchranke, K obereSchranke)
Man erhält eine Sicht auf eine Teilmenge der angesprochenen Abbildung, festgelegt durch
einen Schlüsselbereich beginnend mit der unteren Schranke (inklusive) und endend mit der
oberen Schranke (exklusive). Alle Methoden des View-Objekts wirken sich auf die Origi-
nalkollektion aus.
Im Definitionskopf einer das Interface SortedMap<K,V> implementierenden Klasse (z. B.
TreeMap<K,V>) kann und sollte darauf verzichtet werden, den Typformalparamater K auf die
Schnittstelle Comparable<K> zu restringieren, weil die von SortedMap<K,V> geforderte Metho-
de comparator() (siehe oben) die Vergleichbarkeit der Elemente sicherstellt.
Obwohl eine SortedMap<K,V> - Implementation die equals() - Methode der Schlüsselklasse nicht
für Übereinstimmungsprüfungen verwendet, muss diese Object-Methode trotzdem sinnvoll und
konsistent mit der Methode compareTo() bzw. compare() überschrieben werden, weil anderenfalls
die parametrisierte Klasse den Vertrag der Schnittstelle Map<K,V> verletzt.
Das seit Java 6 vorhandene Interface NavigableMap<K,V> erweitert das Interface
SortedMap<K,V> und ist als Nachfolger vorgesehen. U. a. werden zusätzlich die folgenden Me-
thoden gefordert:
• public Map.Entry<K,V> firstEntry()
Aus der navigierbaren Abbildung wird das Element mit dem ersten (kleinsten) Schlüssel als
Rückgabe geliefert.
• public Map.Entry<K,V> lastEntry()
Aus der navigierbaren Abbildung wird das Element mit dem letzten (größten) Schlüssel als
Rückgabe geliefert.
• public Map.Entry<K,V> pollFirstEntry()
Aus der navigierbaren Abbildung wird das Element mit dem ersten (kleinsten) Schlüssel als
Rückgabe geliefert und gelöscht.
• public Map.Entry<K,V> pollLastEntry()
Aus der navigierbaren Abbildung wird das Element mit dem letzten (größten) Schlüssel als
Rückgabe geliefert und gelöscht.
• public K ceilingKey(K key)
Man erhält als Rückgabe den kleinsten Schlüssel in der navigierbaren Abbildung, der min-
destens ebenso groß ist wie der Aktualparameter.
• public K floorKey(K key)
Man erhält als Rückgabe den größten Schlüssel in der navigierbaren Abbildung, welcher
den Aktualparameter nicht übertrifft.
• public K higherKey(K key)
Man erhält als Rückgabe den kleinsten Schlüssel in der navigierbaren Abbildung, welcher
den Aktualparameter übertrifft.
520 Kapitel 10 Java Collections Framework

• public K lowerKey(K key)


Man erhält als Rückgabe den größten Schlüssel in der navigierbaren Abbildung, welcher
dem Aktualparameter unterlegen ist.
• public Map.Entry<K,V> ceilingEntry(K key)
Man erhält als Rückgabe den Eintrag in der navigierbaren Abbildung mit dem kleinsten
Schlüssel, der mindestens ebenso groß ist wie der Aktualparameter.
• public Map.Entry<K,V> floorEntry(K key)
Man erhält als Rückgabe den Eintrag in der navigierbaren Abbildung mit dem größten
Schlüssel, welcher den Aktualparameter nicht übertrifft.
• public Map.Entry<K,V> higherEntry(K key)
Man erhält als Rückgabe den Eintrag in der navigierbaren Abbildung mit dem kleinsten
Schlüssel, welcher den Aktualparameter übertrifft.
• public Map.Entry<K,V> lowerEntry(K key)
Man erhält als Rückgabe den Eintrag in der navigierbaren Abbildung mit dem größten
Schlüssel, welcher dem Aktualparameter unterlegen ist.
Zum Interface-Datentyp Map.Entry<K,V> siehe die Beschreibung der Map<K,V> - Methode
entrySet() im Abschnitt 10.7.1.
Existiert kein passendes Element, liefern die Methoden firstEntry(), lastEntry(), pollFirstEntry(),
pollLastEntry(), ceilingKey(), floorKey(), higherKey(), lowerKey(), ceilingEntry(),
floorEntry(), higherEntry() und lowerEntry() die Rückgabe null. Im Abschnitt 10.7.4 über die
Klasse TreeMap<K,V> findet sich ein Beispielprogramm, das einige NavigableMap<K,V> - Me-
thoden demonstriert.

10.7.4 Die Klasse TreeMap<K,V>


Analog zu den Verhältnissen bei den Mengenverwaltungsklassen HashSet<E> und TreeSet<E>
gibt es zur Abbildungsverwaltungsklasse HashMap<K,V> für geordnete Schlüsseltypen eine Al-
ternative namens TreeMap<K,V> mit einem balancierten Binärbaum zur Verwaltung der Schlüs-
sel. Diese Klasse erfüllt neben dem Interface Map<K,V> auch die Schnittstellen
SortedMap<K,V> und NavigableMap<K,V> für Abbildungen mit einem geordneten Schlüssel-
typ.
Ersetzt man in der Buchstabenfrequenzen-Methode countLetters() aus dem Abschnitt 10.7.2
das HashMap<Character, Mint> - Objekt durch ein TreeMap<Character, Mint> - Objekt,
public static NavigableMap<Character, Mint> countLetters(String text) {
NavigableMap<Character, Mint> fred = new TreeMap<>();
Mint temp;
for (int i = 0; i < text.length(); i++) {
char pc = text.charAt(i);
if (Character.isLetter(pc)) {
Character c = pc;
if (fred.containsKey(c)) {
temp = fred.get(c);
temp.val++;
fred.replace(c, temp);
} else
fred.put(c, new Mint(1));
}
}
return fred;
}
Abschnitt 10.8 Vergleich der Kollektionsarchitekturen 521

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

Im folgenden Programm werden einige Methoden aus dem Interface NavigableMap<Integer,


String> (vgl. Abschnitt 10.7.3) vorgeführt:
Quellcode Ausgabe
import java.util.*; {1=a, 2=b, 4=d}
class Prog { a
public static void main(String[] args) { {2=b, 4=d}
NavigableMap<Integer,String> tms = new TreeMap<>();
tms.put(1,"a"); tms.put(2,"b"); tms.put(4,"d"); ceilingKey(3) = 4
System.out.println(tms); floorKey(3) = 2
heigherKey(4) = null
Map.Entry<Integer,String> fi = tms.firstEntry(); lowerKey(4) = 2
System.out.println(fi.getValue());

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.8 Vergleich der Kollektionsarchitekturen


In der folgenden Tabelle sind für die zentralen Kollektionsarchitekturen im JCF einige Merkmale
und Operationskomplexitäten zusammengefasst:
Kollektionsarchitektur
Merkmale/Operationen Array- Verkettete Hash- Binär-
basiert Liste tabelle baum
Definierbare Positionen der Elemente1 + + - -
Merkmale

Automatische Anordnung nach Inhalt - - - +


Frei von Dubletten - - + +
Opera-

Existenzprüfung O(n) O(n) O(1) O(log2 n)


tionen

Elemente einfügen oder löschen O(n) O(1) O(1) O(log2 n)


1
Per Voreinstellung wird die Einfügereihenfolge konserviert, doch kann die Position eines Elements beliebig festgelegt
werden.
522 Kapitel 10 Java Collections Framework

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

10.10 Nützliche Methoden in der Klasse Collections


Die Klasse Collections erbringt durch statische und teilweise generische Methoden zahlreiche
Dienstleistungen für Kollektionsobjekte, von denen die Fähigkeit, eine thread-sichere (synchroni-
sierte) Hülle zu einem Kollektionsobjekt zu liefern, bereits mehrfach erwähnt worden ist:
• public static <T extends Object & Comparable<? super T>>
T max(Collection<? extends T> coll)
Diese Methode liefert das größte Element einer Kollektion mit einem geordneten Element-
typ. Durch die erste, scheinbar überflüssige Restriktion (T extends Object) für den Typfor-
malparameter T, wird aus Kompatibilitätsgründen dafür gesorgt, dass im Bytecode (nach
der Typlöschung) der Rückgabetyp Object steht (statt Comparable).2 Wie im Abschnitt
8.1.3.2 erläutert wurde, orientiert sich die Typlöschung bei multiplen Bindungen ausschließ-
lich an der ersten Bindung. Mit der zweiten Restriktion (T extends Comparable<? super
T>) wird vom Typ T eine Methode compareTo() verlangt, wobei T selbst oder eine Basis-
klasse von T als Parametertyp erlaubt ist. Damit ist insgesamt als T-Konkretisierung auch
eine Klasse möglich, die die Methode compareTo() nicht selbst implementiert, sondern von
einer Basisklasse erbt (vgl. Abschnitt 9.3).
• public static <T extends Object & Comparable<? super T>>
T min(Collection<? extends T> coll)
Diese Methode liefert das kleinste Element einer Kollektion mit einem geordneten Element-
typ.
• public static <T extends Comparable<? super T>> void sort(List<T> list)
Eine Liste wird unter Verwendung der (eventuell geerbten) compareTo() - Methode ihres
Elementtyps sortiert.
• public static <T> void sort(List<T> list, Comparator<? super T> comp)
Eine Liste wird unter Verwendung der vom zweiten Parameter-Objekt beherrschten Metho-
de compare() sortiert.

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

• public static void reverse(List<?> list)


Die Elemente einer Liste erhalten eine umgekehrte Reihenfolge.
• public static void shuffle(List<?> list)
Diese Methode bringt die Elemente einer Liste in eine neue, zufällige Reihenfolge. Dank der
Konvertierungsmethode asList() aus der Klasse Arrays lässt sich mit Hilfe der Methode
shuffle() auch ein Array verwirbeln, z. B.:1
Integer[] ai = new Integer[] {1, 2, 3, 4, 5, 6, 7};
Collections.shuffle(Arrays.asList(ai));
Die Methode asList() liefert eine Sicht auf den Array, die das parametrisierte Interface
List<Integer> erfüllt, und die Methode shuffle() ändert direkt den Array. Dabei wird ein
Array mit Referenz-Elementtyp vorausgesetzt.
• public static <T> int binarySearch(List<? extends Comparable<? super T>> list, T obj)
Diese Methode durchsucht eine aufsteigend sortierte Liste nach einem Objekt unter Ver-
wendung des Halbierungsverfahrens. Sie liefert den Index eines Treffers oder eine ganze
Zahl < 0.
• public static <E> Collection<E> synchronizedCollection(Collection<E> coll)
public static <E> List<E> synchronizedList(List<E> list)
public static <E> Set<E> synchronizedSet(Set<E> set)
public static <K,V> Map<K,V> synchronizedMap(Map<K,V> map)
Zu einem Container, der die Schnittstelle Collection<E>, List<E>, Set<E> oder
Map<K,V> erfüllt, erhält man eine thread-sichere (synchronisierte) Verpackung. Was das
genau bedeutet, wird im Kapitel 15 über Multithreading erläutert. Dabei wird sich allerdings
im Abschnitt 15.6 zeigen, dass nur eine bedingte Thread-Sicherheit vorliegt.
• public static <E> Collection<E> unmodifiableCollection(Collection<? extends E> coll)
public static <E> List<E> unmodifiableList(List<? extends E> list)
public static <E> Set<E> unmodifiableSet(Set<? extends E> set)
public static <K,V> Map<K,V> unmodifiableMap(Map<? extends K, ? extends V>
map)
Zu einem Container, der die Schnittstelle Collection<E>, List<E>, Set<E> oder
Map<K,V> erfüllt, erhält man eine Sicht, die zwar einen lesenden, aber keinen schreiben-
den Zugriff auf die Elemente erlaubt.
• public static final <E> List<E> emptyList()
public static final <E> Set<E> emptySet()
public static final <K,V> Map<K,V> emptyMap()
Wenn eine Methode eine erwartete Rückgabe mit Kollektionstyp nicht erstellen kann, ist die
Rückgabe einer leeren Kollektion eventuell gegenüber alternativen Kommunikationsverfah-
ren (Rückgabe von null, Ausnahmefehler) zu bevorzugen.
Im folgenden Programm werden einige Collections-Methoden demonstriert:

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));
}
}

10.11 Übungsaufgaben zum Kapitel 10


1) Erstellen Sie ein Programm, das 6 Lottozahlen (von 1 bis 49) zieht und sortiert ausgibt. Diese
Aufgabe haben Sie schon einmal mit Hilfe von Array-Techniken gelöst (siehe Abschnitt 5.6). Das
JCF sorgt dafür, dass sich der Lösungsaufwand reduziert.

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));

NavigableMap<Character,Integer> freqs = StringUtil.getStartCharFreqs(s);


System.out.println("\nAnfangsbuchstabe Häufigkeit");
for (Character c : freqs.keySet())
System.out.printf("%-17c %d\n", c, freqs.get(c));
}
}
Es sollte die folgende Ausgabe liefern:
526 Kapitel 10 Java Collections Framework

[In, diesem, Satz, kommt, der, Anfangsbuchstabe, a, zweimal, vor.]

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);
}
}

die folgende Ausgabe liefern:


Menge A
a
b
c

Menge B
b
c
d
Abschnitt 10.11 Übungsaufgaben zum Kapitel 10 527

Durchschnitt von A und B


b
c

Vereinigung von A und B


a
b
c
d

Differenz von A und B


a

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

Anything that can go wrong will go wrong.


Eine Besonderheit von Java besteht in der Unterscheidung zwischen Ausnahmeklassen, für die eine
potentiell betroffene Methode einen Exception-Handler bereithalten muss (z. B. IOException) und
Ausnahmeklassen, bei denen die Entscheidung über eine Vorbereitung dem Programmierer überlas-
sen wird (z. B. NumberFormatException).

11.1 Prävention und Beispielprogramm


In diesem Kapitel geht es primär darum, die Fehlerkommunikation über Ausnahmeobjekte kennen-
zulernen, zunächst in der reagierenden und später auch in der agierenden Rolle. Obwohl sich Aus-
nahmefehler praktisch nie vollständig vermeiden lassen, ist es oft sinnvoll, einen mit erheblicher
Wahrscheinlichkeit zu erwartenden Ausnahmefehler durch präventive Maßnahmen (Kontrollen) zu
verhindern. Im folgenden Beispielprogramm ist eine solche präventive Maßnahme zu sehen. Das
Programm wird aber nicht von allen Ausnahmefehlern verschont bleiben.
Zweck des Programms ist die Berechnung der Fakultät zu einer nichtnegativen ganzen Zahl, die
beim Start als Programmargument übergeben wird. Die eigentliche Fakultätsberechnung findet in
der main() - Methode statt, während die Konvertierung und Validierung der übergebenen Zeichen-
folge in der Methode convertInput() erfolgt. Diese wiederum stützt sich bei der Konvertierung
auf die statische Methode parseInt() der API-Klasse Integer:
class Fakul {
static int convertInput(String instr) {
int arg = Integer.parseInt(instr);
if (arg >= 0 && arg <= 170)
return arg;
else
return -1;
}

public static void main(String[] args) {


int argument = -1;

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]);
}
}

Die Methode main() überprüft, ob tatsächlich ein Kommandozeilenargument in args[0] vorhan-


den ist, bevor sie diese String-Referenz beim Aufruf der Methode convertInput() als Parameter
verwendet. Damit wird verhindert, dass es zu einer ArrayIndexOutOfBoundsException kommt,
wenn der Benutzer das Programm ohne Kommandozeilenargument startet.
Weil das Programm in dieser Situation kein Fakultätsargument und auch keine Möglichkeit zum
Befragen des Benutzers hat, informiert es über das Problem und beendet sich durch einen Aufruf
Abschnitt 11.1 Prävention und Beispielprogramm 531

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).

11.2 Unbehandelte Ausnahmen


Das Beispielprogramm im Abschnitt 11.1 ist trotz seiner präventiven Bemühungen zur Vermeidung
eines Ausnahmefehlers vom Typ ArrayIndexOutOfBoundsException leicht aus dem Tritt zu
bringen, indem man es mit einer nicht konvertierbaren Zeichenfolge füttert (z. B. „vier“). Die zu-
nächst betroffene Methode2 Integer.parseInt() wirft daraufhin eine NumberFormatException.
Diese wird vom Laufzeitsystem entlang der Aufrufreihenfolge an convertInput() und dann an
main() gemeldet:
main()

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

In dieser Ausgabe der vom Ausnahmeobjekt ausgeführten Throwable-Methode printStackTrace()


sind enthalten (vgl. Abschnitt 11.3.3):
• Name der Ausnahmeklasse (im Beispiel: java.lang.NumberFormatException)
• Fehlermeldung (im Beispiel: For input string: "vier")
• Aufrufersequenz von der Unfallstelle bis zur Methode main()
Trotz der planhaften Vorgehensweise der Laufzeitumgebung und der zahlreichen brauchbaren In-
formationen erlebt der Benutzer einen „Programmabsturz“ und denkt eventuell über alternative
Programme nach.
Wird ein Programm im Rahmen unsere Entwicklungsumgebung IntelliJ ausgeführt, dann besitzt der
Unfallbericht eine auffällige Färbung und klickbare Verknüpfungen zu den Quellcodezeilen der
betroffenen Methoden in der Aufrufersequenz, z. B.:
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)

11.3 Ausnahmen abfangen


Die Startversion des Programms zur Fakultätsberechnung beherrscht weder das Behandeln noch das
Werfen von Ausnahmen. Wir machen uns nun daran, diese kommunikativen Kompetenzen nachzu-
rüsten.

11.3.1 Die try-catch-finally - Anweisung


In Java wird die Behandlung von Ausnahmen über die try-catch-finally - Anweisung unterstützt:

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).

11.3.1.1 Ausnahmebehandlung per catch-Block


Tritt im try-Block eine Ausnahme auf, dann wird seine Ausführung abgebrochen, und das Lauf-
zeitsystem sucht in der try-Anweisung nach einem catch-Block, welcher eine Ausnahme der be-
troffenen Klasse behandeln kann.
Ein catch-Block, den man auch als Exception-Handler bezeichnet, verfügt in Analogie zu einer
Methode in seinem Kopfbereich über eine Typangabe und einen Formalparameter. Vor Java 7
konnte pro Exception-Handler nur eine zu behandelnde Ausnahmeklasse angegeben werden, z. B.:
catch (NumberFormatException e) {
. . .
}
Seit Java 7 ist neben diesem Single-Catch - Block auch ein Multiple-Catch - Block mit einer Liste
von Ausnahmeklassen erlaubt, für die eine einheitliche Behandlung vereinbart werden soll, z. B.:
catch (NumberFormatException | ArithmeticException e) {
. . .
}
Bei einem Multi-Catch - Block sind folgende Regeln zu beachten:
• Die Namen der Ausnahmeklassen werden durch einen senkrechten Strich | getrennt, der be-
kanntlich (zwischen zwei logischen Ausdrücken) auch für die logische ODER-Operation
steht (vgl. Abschnitt 3.5.5). Das ist eine gute Wahl, denn im obigen Beispiel wird der
Exception-Handler aktiv, wenn eine NumberFormatException oder eine
ArithmeticException aufgetreten ist.
• Es ist es verboten (und auch sinnlos), neben einer Klasse K auch eine von K abgeleitete Klas-
se in die Liste aufzunehmen.
• Ein Multi-Catch - Block wird vom Compiler in entsprechend viele, hintereinander stehende
Single-Catch - Blöcke mit identischen Anweisungen umgesetzt.
Das Laufzeitsystem sucht für ein zu behandelndes Ausnahmeobjekt nach einem catch-Block mit
einer passenden Ausnahmeklasse und führt ggf. den zugehörigen Anweisungsblock aus. Für jedes
Ausnahmeobjekt wird maximal ein catch-Block ausgeführt. Weil die Liste der catch-Blöcke von
oben nach unten durchsucht wird, müssen breitere Ausnahmeklassen stets unter spezielleren stehen.
Freundlicherweise stellt der Compiler die Einhaltung dieser Regel sicher.
In der folgenden Variante der Methode convertInput() aus unserem Beispielprogramm zur Fa-
kultätsberechnung wird eine von Integer.parseInt() ausgelöste NumberFormatException abge-
Abschnitt 11.3 Ausnahmen abfangen 535

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:

In der Methode main() muss der neue Fehlerindikator berücksichtigt werden:


public static void main(String args[]) {
int argument = -1;

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

Beim Programmstart mit einem nicht-konvertierbaren Kommandozeilenargument erscheint nun


eine informative Fehlermeldung an Stelle eines „Absturzprotokolls“ der JVM, z. B.:
Fehler beim Konvertieren von: "vier"
Je nach Algorithmus kommen als Aufgaben für einen catch-Block in Frage (selbstverständlich auch
im Kombination):
• Reparatur
Manchmal ist es möglich, den aufgetretenen Fehler zu beheben oder zu umgehen.
• Rückabwicklung
Man kann versuchen, bereits realisierte und aufgrund der Ausnahme nunmehr unerwünschte
Effekte des unterbrochenen try-Blocks wieder rückgängig zu machen.
• Ersetzung des Ausnahmeobjekts durch eine informativere Alternative
Viele catch-Blöcke betätigen sich als Informationsvermittler und werfen selbst eine Aus-
nahme, um dem Aufrufer einen leichter verständlichen Unfallbericht zu liefern (siehe Ab-
schnitt 11.6).
• Fehlermeldung und/oder Fehlerprotokollierung
Wenn eine gescheiterte Operation abgebrochen werden muss, dann sollte der Benutzer eine
gut verständliche Fehlermeldung erhalten. Ein Eintrag in eine Logdatei kann den Software-
Entwickler oder einen Administrator dabei unterstützen, die Ursache des Fehlers zu finden
(siehe Kapitel 14 zur Dateiausgabe). Nach einer Fehlermeldung oder -protokollierung ist es
in der Regel sinnvoll, die abgefangene Ausnahme erneut zu werfen.
Es sollte verhindert werden, dass ein Objekt durch einen gescheiterten Methodenaufruf in einen
defekten Zustand gerät. Wenn das nicht möglich ist, müssen andere Programmierer durch eine klare
Dokumentation davon abhalten werden, ein havariertes Objekt nach einer Ausnahmebehandlung
weiter zu verwenden.
Eine Ausnahme nur scheinbar zu behandelt und letztlich zu ignorieren bzw. zu eliminieren, ist eine
riskante Praxis, die auf jeden Fall in einem Kommentar begründet werden muss. Die folgende try-
Anweisung stammt aus einem Beispielprogramm, das im Abschnitt 11.3.1.2 vorgestellt wird:
try {
Thread.sleep(3000);
} catch (InterruptedException ignored) {
// Die Ausnahme darf ignoriert werden, weil der Thread nicht abgebrochen wird.
}
Durch die statische Thread-Methode sleep() wird der aktuelle Thread in einen Schlaf von 3000
Millisekunden Dauer versetzt. Beantragt in dieser Zeit ein anderer Thread, dass der schlafende
Thread abgebrochen werden soll, dann wird die Methode sleep() aktiv, um eine Verzögerung bei
der Antragsbearbeitung zu vermeiden. Die Methode sleep() wirft in dieser Situation eine Interrup-
tedException, die den Schläfer reaktiviert und veranlasst, eine Terminierung seiner Tätigkeit in
Erwägung zu ziehen. Bei der InterruptedException handelt sich um eine sogenannte geprüfte
Ausnahme, auf die man sich vorbereiten muss (siehe Abschnitt 11.5.2). Im konkreten Beispielpro-
gramm (siehe Abschnitt 11.3.1.2) findet aber kein Unterbrechungsversuch statt, sodass keine Inter-
ruptedException auftreten kann und keine ernsthafte Behandlung erforderlich ist. Eine Interrup-
tedException darf keinesfalls generell ignoriert werden, und jedes Ignorieren (also Eliminieren)
einer Ausnahme (gleichgültig aus welcher Ausnahmeklasse) muss sorgfältig erwogen und doku-
mentiert werden.
Wenn ein catch-Block erfolgreich (d .h. ohne weiteres Ausnahmeereignis) ausgeführt worden ist,
dann wird die Methode hinter der try-Anweisung fortgesetzt, wobei ggf. vorher noch der finally-
Block ausgeführt wird. Der finally-Block wird auch dann ausgeführt, wenn die Methode im catch-
Block per return-Anweisung oder durch eine neue Ausnahme verlassen wird. Weitere Details zum
Abschnitt 11.3 Ausnahmen abfangen 537

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.

11.3.1.2 Aufräumarbeiten im finally-Block


In einen finally-Block gehören Anweisungen, die auf jeden Fall ausgeführt werden sollen:
• Nach der ungestörten Ausführung des try-Blocks
• Nach einer Ausnahmebehandlung in einem catch-Block (auch beim Verlassen des catch-
Blocks durch eine neue Ausnahme)
• Nach dem Auftreten einer unbehandelten Ausnahme im try-Block
• Beim Beenden der Methode durch eine return-Anweisung im try-Block oder in einem
catch-Block
Vor Java 7 wurde der finally-Block meist dazu verwendet, Ressourcen wie Datei - und Netzverbin-
dungen freizugeben. Für diesen Zweck stellt Java seit der Version 7 mit der try-with-resources -
Anweisung jedoch eine weitaus bessere Lösung zur Verfügung (siehe Abschnitt 11.10). Daher fällt
es etwas schwer, ein plausibles und einfaches Anwendungsbeispiel für den finally-Block zu finden.
Für das folgende Beispiel ist ein Vorgriff auf das Kapitel über Multithreading erforderlich. Es wird
die Verwaltung eines Bankkontos durch zwei Threads (nebenläufige Ausführungsfäden des Pro-
gramms) simuliert:
• Ein freundlicher Thread zahlt ständig Geldbeträge auf das Konto ein.
• In einem gleichzeitig aktiven Thread darf der Benutzer Geld abheben.
Über ein Sperrobjekt aus der Klasse ReentrantLock (Paket java.util.concurrent.locks) wird ver-
hindert, dass beide Threads gleichzeitig auf das Konto zugreifen, weil dabei ein fehlerhaftes Verhal-
ten des Programms resultieren könnte (vgl. Abschnitt 15.2.3). Die Methode zum Abheben aktiviert
die Sperre, erfragt dann beim Benutzer den gewünschten Betrag, erleichtert das Konto und gibt die
Sperre schließlich frei, damit der Einzahlungs-Thread wieder Zugang zum Konto erhält. Um die
Wünsche des Benutzers entgegenzunehmen, wird mit der Methode nextLine() der Klasse Scanner
aus dem Paket java.util (vgl. Abschnitt 3.4.1) eine mit Enter quittierte Zeile von der Konsole gele-
sen und anschließend mit der Methode parseInt() der Klasse Integer eine Interpretation der Einga-
be versucht. Die Methode parseInt() reagiert auf eine nicht interpretierbare Eingabe mit dem Wer-
fen eines Ausnahmeobjekts vom Typ NumberFormatException (siehe Abschnitt 11.2). Daher
wird sie im Rahmen einer try-Anweisung mit catch-Block für die NumberFormatException auf-
gerufen:
import java.util.*;
import java.util.concurrent.locks.ReentrantLock;

class FinallyDemo implements Runnable {


int konto;
ReentrantLock lock = new ReentrantLock();
Random ran = new Random();
538 Kapitel 11 Ausnahmebehandlung

public void run() {


while (true) {
lock.lock();
konto += ran.nextInt(30);
System.out.print("\nKontostand erhöht auf: " + konto);
lock.unlock();
try {
Thread.sleep(1000);
} catch (InterruptedException ignored) {
// Die Ausnahme darf ignoriert werden, weil der Thread nicht abgebrochen wird.
}
}
}

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();
}
}
}

public static void main(String args[]) {


FinallyDemo fd = new FinallyDemo();
(new Thread(fd)).start();
fd.abheben();
}
}

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!

Kontostand erhöht auf: 84


Kontostand erhöht auf: 91
Kontostand erhöht auf: 112
Abschnitt 11.3 Ausnahmen abfangen 539

Welcher Betrag soll abgehoben werden (Beenden mit Betrag < 0): 4
Neuer Kontostand: 108

Kontostand erhöht auf: 112


Kontostand erhöht auf: 113
Kontostand erhöht auf: 126

Im Programm wird an zwei Stellen die behandlungspflichtige InterruptedException ignoriert, die


von der statischen Thread-Methode sleep() zu erwarten ist, z. B.:
try {
Thread.sleep(3000);
} catch (InterruptedException ignored) {
// Die Ausnahme darf ignoriert werden, weil der Thread nicht abgebrochen wird.
}
Im Abschnitt 11.3.1.1 wurde begründet, dass in diesem Fall das Ignorieren bzw. Eliminieren einer
Ausnahme gerechtfertigt ist.

11.3.2 Programmablauf bei der Ausnahmebehandlung


Findet die JVM für eine Ausnahme in der aktuellen Methode keinen zuständigen catch-Block, dann
sucht sie entlang der Aufrufersequenz weiter. Dies macht es leicht, die Behandlung einer Ausnahme
der bestgerüsteten Methode zu überlassen. Im folgenden Beispiel dürfen Sie allerdings keine opti-
mierte Einsatzplanung erwarten. Es demonstriert einige Programmabläufe infolge von Ausnahmen,
die auf verschiedenen Stufen einer Aufrufhierarchie geworfen bzw. behandelt werden. Um das Bei-
spiel einfach zu halten, wird auf Praxisnähe verzichtet. Das Programm nimmt via Kommandozeile
ein Argument entgegen, interpretiert es numerisch und ermittelt den Rest aus der Division der Zahl
10 durch das Argument:
class Sequenzen {
static int calc(String instr) {
int arg = 0;
try {
System.out.println("try-Block von calc()");
arg = Integer.parseInt(instr);
arg = 10 % arg;
} catch (NumberFormatException e) {
System.out.println("NumberFormatException-Handler in calc()");
} finally {
System.out.println("finally-Block von calc()");
}
System.out.println("Nach try-Anweisung in calc()");
return arg;
}

public static void main(String[] args) {


try {
System.out.println("try-Block von main()");
System.out.println("10 % " + args[0] + " = " + calc(args[0]));
} catch (ArithmeticException e) {
System.out.println("ArithmeticException-Handler in main()");
} finally {
System.out.println("finally-Block von main()");
}
System.out.println("Nach try-Anweisung in main()");
}
}
Die Methode main() lässt die eigentliche Arbeit von der Methode calc() erledigen und bettet de-
ren Aufruf in eine try-Anweisung mit catch-Block für die ArithmeticException ein, die das Lauf-
540 Kapitel 11 Ausnahmebehandlung

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()

b) Exception in calc(), die dort auch behandelt wird


Wird beim Ausführen der Anweisung
arg = Integer.parseInt(instr);
eine NumberFormatException an calc() gemeldet (z. B. wegen Kommandozeilen-Argument
„acht“ von parseInt() geworfen), dann kommt der zugehörige catch-Block zum Einsatz. Dann fol-
gen:
• finally-Block in calc()
• restliche Anweisungen in calc() (hinter der try-Anweisung)
Im try-Block von calc() hinter dem Unfallort stehende Anweisungen werden nicht ausgeführt. So
wird verhindert, dass ein Algorithmus mit fehlerhaften Zwischenergebnissen weiterläuft. Wenn eine
Methode auf traditionelle Weise per Rückgabewert einen Fehler signalisiert, kann es hingegen pas-
sieren, dass die warnende Rückgabe ignoriert und der laufende Algorithmus fortgesetzt wird (vgl.
Abschnitt 11.4).
An main() wird keine Ausnahme gemeldet, also werden hier nacheinander ausgeführt:
• try-Block
• finally-Block
• restliche Anweisungen
Insgesamt erhält man die folgenden Ausgaben:
try-Block von main()
try-Block von calc()
NumberFormatException-Handler in calc()
finally-Block von calc()
Nach try-Anweisung in calc()
10 % acht = 0
finally-Block von main()
Nach try-Anweisung in main()
Zu der unsinnigen Ausgabe
10 % acht = 0
Abschnitt 11.3 Ausnahmen abfangen 541

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.

c) Exception in calc(), die in main() behandelt wird


Wird vom Laufzeitsystem eine ArithmeticException an calc() gemeldet (z. B. wegen Komman-
dozeilen-Argument „0“), dann findet sich in dieser Methode kein passender Handler. Weil die Aus-
nahme im try-Block einer try-catch-finally - Anweisung auftrat, wird noch der zugehörige finally-
Block ausgeführt, bevor die Methode verlassen wird, um entlang der Aufrufsequenz nach einem
geeigneten Handler zu suchen.
In main() findet sich ein ArithmeticException-Handler, der nun zum Einsatz kommt. Dann geht es
weiter mit dem zugehörigen finally-Block. Schließlich wird das Programm hinter der try-
Anweisung der Methode main() fortgesetzt:
try-Block von main()
try-Block von calc()
finally-Block von calc()
ArithmeticException-Handler in main()
finally-Block von main()
Nach try-Anweisung in main()

d) Exception in main(), die nirgends behandelt wird


Übergibt der Benutzer kein Kommandozeilen-Argument, tritt in main() bei Zugriff auf args[0]
eine ArrayIndexOutOfBoundsException auf (vom Laufzeitsystem geworfen). Weil sich kein zu-
ständiger Handler findet, wird das Programm vom Laufzeitsystem beendet. Zuvor wird der finally-
Block von main() noch ausgeführt, die Anweisungen hinter der try-Anweisung aber nicht mehr:
try-Block von main()
finally-Block von main()
Exception in thread "main" java.lang.ArrayIndexOutOfBoundsException: 0
at Sequenzen.main(Sequenzen.java:23)

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.

11.3.3 Diagnostische Ausgaben


Ein Ausnahmeobjekt enthält viele Informationen, die sich für diagnostische Ausgaben eignen. Statt
im catch-Block eine eigene Fehlermeldung zu formulieren, kann man die toString() - Methode des
übergebenen Ausnahmeobjekts aufrufen, was hier implizit im Rahmen eines println() - Aufrufs
geschieht:
catch (NumberFormatException e) {
System.out.println(e);
}
542 Kapitel 11 Ausnahmebehandlung

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

11.4 Ausnahmeobjekte im Vergleich zur Fehlerkommunikation per Rückgabe

11.4.1 Traditionelle Rückgabewerte


Die traditionelle Fehlerbehandlung verwendet meist den Rückgabewert einer Methode, um über
Störungen bei der Ausführung der Methode zu informieren. Ein Rückgabewert kann …
• ausschließlich zur Fehlermeldung dienen. Meist wird dann ein ganzzahliger Returncode mit
dem Datentyp int verwendet, wobei die 0 einen erfolgreichen Ablauf signalisiert, während
andere Zahlen für bestimmte Fehlertypen stehen. Soll nur zwischen Erfolg und Misserfolg
unterschieden werden, dann bietet sich der Rückgabewert boolean an.
• neben den Ergebnissen einer ungestörten Ausführung durch spezielle Werte auch Störungen
signalisieren. Diese Technik wird im Beispielprogramm von Abschnitt 11.3.1.1 und auch in
der Methode read() der API-Klasse FileInputStream verwendet:
public int read()
throws IOException
Reads a byte of data from this input stream. This method blocks if no input is yet available.
Specified by:
read in class InputStream
Returns:
the next byte of data, or -1 if the end of the file is reached.
Throws:
IOException - if an I/O error occurs.
Eine für die aktuelle Diskussion interessante, durchaus nicht ungewöhnliche Eigenschaft der
Methode read() besteht darin, dass ...
o einerseits per Rückgabewert (mit kombinierter Bedeutung) über ein mit erheblicher
Wahrscheinlichkeit beim Lesen aus der Eingabedatei zu erwartendes Problem infor-
miert wird,
o und andererseits per Ausnahmeobjekt (aus der Klasse IOException) über weniger
wahrscheinliche Fehler informiert wird.
Sollen z. B. drei Methoden mit ausschließlich zur Fehlermeldung dienenden Rückgabewerten nach-
einander aufgerufen werden, dann wird die vom Algorithmus diktierte simple Sequenz:
public static void main(String[] args) {
m1();
m2();
m3();
}
nach dem Ergänzen der Fehlerbehandlungen zu einer unübersichtlichen Konstruktion:
public static void main(String[] args) {
int returncode;

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.

11.4.2 Rückgabetyp Optional<T>


Im letzten Abschnitt wurde die in Java von Anfang an enthaltene Kommunikation per Ausnahme-
objekt mit der viel älteren Fehlerkommunikation über den Rückgabewert verglichen. In diesem Ab-
schnitt wird eine relativ junge, in Java 8 (alias 1.8) eingeführte Programmiertechnik beschrieben,
die ebenfalls mit dem Rückgabewert arbeitet und potentiell als Alternative zum Werfen eines Aus-
nahmeobjekts in Frage kommt (siehe Bloch 2018, S. 249ff).
Zur Unterstützung der funktionalen, strombasierten Programmierung (siehe Kapitel 12) wurde in
Java 8 die Klasse Optional<T> eingeführt. Ihre unveränderlichen Objekte enthalten als Container
entweder eine von null verschiedene Referenz oder sind leer, wobei die Methode isPresent() die
Rückgabe false liefert. Der Rückgabetyp Optional<T> eignet sich für Methoden, die unter Um-
ständen eine angeforderte Rückgabe nicht liefern können, weil ein leerer Container als Rückgabe
sinnvoller sein kann als ...
• das Werfen einer Ausnahme
Das Werfen einer Ausnahme sollte nur unter außergewöhnlichen Umständen passieren (vgl.
Abschnitt 11.4.1).
• oder der Rückgabewert null
Wenn der Aufrufer nicht mit der null-Rückgabe rechnet, kommt es eventuell im weiteren
Programmverlauf zu einer NullPointerException. Der Rückgabetyp Optional<T> signali-
siert dem Aufrufer explizit, dass er mit einem leeren Rückgabeobjekt rechnen muss.
Im folgenden Beispielprogramm nach Bloch (2018, S. 250) liefert die statische und generische Me-
thode max() eine Rückgabe vom Typ Optional<E>. Der Aufrufer erhält das maximale Element
einer existenten Parameterkollektion, die ...
• weder leer ist
• noch ausschließlich null-Elemente enthält.
Abschnitt 11.5 Ausnahmen und Fehler 547

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));
}
}

Die statische Optional<E> - Methode


public static <E> Optional<E> ofNullable(E value)
liefert ...
• einen Optional<E> - Container mit dem existenten Parameterobjekt
• einen leeren Optional<E> - Container, wenn der Parameter gleich null ist.
Für primitive Datentypen sind die analog zu Optional<E> arbeitenden Klassen OptionalInt,
OptionalDouble etc. vorhanden.

11.5 Ausnahmen und Fehler


In der folgenden Abbildung sind wichtige, teilweise im weiteren Textverlauf noch anzusprechende
Klassen für Ausnahmen und noch gravierendere Probleme mit ihren Vererbungsbeziehungen zu
sehen:
548 Kapitel 11 Ausnahmebehandlung

Object

Throwable

Error Exception

LinkageError VirtualMachineError

NoClassDefFoundError

AssertionError OutOfMemoryError StackOverflowError

InterruptedException IOException IllegalAccessException RuntimeException

IllegalArgumentException IndexOutOfBoundsException ArithmeticException NullPointerException

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

assert Logischer Ausdruck : Ausdruck mit Wert ;

Es folgt ein mäßig sinnvolles Beispiel:


class Prog {
public static void main(String[] args) {
int arg = Integer.parseInt(args[0]);
assert arg != 0 : "int-Division durch 0";
System.out.println(3/arg);
}
}

Bei der Programmausführung werden assert-Anweisungen per Voreinstellung ignoriert, sodass


keine Überprüfungen mit negativem Einfluss auf das Zeitverhalten des Programms stattfinden. Im
Beispiel wird also nach dem Programmstart mit
>java Prog 0
eine ArithmeticEception geworfen:
Exception in thread "main" java.lang.ArithmeticException: / by zero
at Prog.main(Prog.java:5)
Um die assert-Anweisungen zu aktivieren, ist beim Aufruf des Java-Starters der Schalter -ea (ena-
ble assertions) zu setzen, z. B.:
>java -ea Prog 0
Im Beispiel resultiert die Ausgabe:
Exception in thread "main" java.lang.AssertionError: int-Division durch 0
at Prog.main(Prog.java:5)
In IntelliJ ist per Ausführungskonfiguration eine VM option zu setzen, um die assert - Anweisun-
gen zu aktivieren, z. B.:
Abschnitt 11.5 Ausnahmen und Fehler 551

Man kann eine eigene Klasse von Error ableiten, sollte es aber besser nicht tun (siehe Bloch 2018,
S. 297).

11.5.2 Geprüfte und ungeprüfte Ausnahmen


Ausnahmen aus der Klasse Exception oder aus einer Spezialisierung davon sind zur Behandlung
vorgesehen. Zwischen den Klassen aus der Exception-Hierarchie bestehen Unterschiede hinsicht-
lich des Zwangs zur Behandlung.

11.5.2.1 Unterschiedliche Behandlung durch den Compiler


Bei Ausnahmeobjekten aus der Klasse RuntimeException und aus daraus abgeleiteten Klassen
(siehe die Klassenhierarchie zu Beginn von Abschnitt 11.5) ist es dem Programmierer freigestellt,
ob er sich auf eine Behandlung vorbereiten möchte. Weil der Compiler nicht prüft, ob eine Behand-
lung erfolgt, spricht man von ungeprüften Ausnahmen (engl.: unchecked exceptions). Alle übrigen
Ausnahmeobjekte (z. B. aus der Klasse IOException) müssen hingegen behandelt werden. Weil der
Compiler dies kontrolliert, spricht man von geprüften Ausnahmen (engl.: checked exceptions).
Bei der Verwendung einer Methode, die Laufzeitprobleme über geprüfte Ausnahmen meldet, muss
der Aufrufer …
• entweder den Aufruf in einer try-Anweisung mit geeignetem catch-Block vornehmen (vgl.
Abschnitt 11.3)
• oder im eigenen Definitionskopf per throws-Klausel das Weiterreichen der Ausnahmen an
den eigenen Aufrufer ankündigen (siehe unten).
Bei einer Ausnahmeklasse ohne Behandlungszwang ist eine solche Vorbereitung nicht erforderlich,
aber selbstverständlich erlaubt und oft sinnvoll. Eine geworfene Ausnahme muss unabhängig von
ihrer Klassenzugehörigkeit auf jeden Fall behandelt werden, um die Beendigung des Programms
(genauer: des betroffenen Threads) durch das Laufzeitsystem zu verhindern.
Ausnahmeobjekte werden auch in vielen anderen Programmiersprachen unterstützt, wobei aber nur
Java zwischen geprüften und ungeprüften Ausnahmen unterscheidet. In anderen Sprachen (z. B. C#,
C++, Kotlin, Scala) sind alle Ausnahmen vom ungeprüften Typ. Im Bemühen um robuste Pro-
gramme zwingt Java zur Behandlung von Ausnahmefehlern, hebt aber den Zwang für zwei Thro-
wable-Unterklassen auf:
552 Kapitel 11 Ausnahmebehandlung

• 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).

11.5.2.2 Eine schwierige Unterscheidung


Die API-Designer haben sich bei der Klassifikation einer Ausnahme als geprüft oder ungeprüft of-
fenbar von der Ursache und vom Ausmaß des Problems leiten lassen. Die Ausnahmen zur Meldung
eines Programmierfehlers (z. B. NullPointerException, ArrayIndexOutOfBoundsException)
sind ungeprüft (mit der Basisklasse RuntimeException). Beim Aufruf einer Methode muss man
sich nicht darauf vorbereiten, auf einen solchen Fehler zu reagieren. Stattdessen muss der Fehler
schleunigst beseitigt werden. Es wäre vollständig unzumutbar, wenn man für jeden Array-Zugriff
wegen der potentiellen ArrayIndexOutOfBoundsException eine try-Anweisung verwenden
müsste.
Haben vom Programmierer nicht zu kontrollierende externe Bedingungen das Problem verursacht
(z. B. Netzwerkstörungen), dann orientiert sich die Entscheidung für oder gegen den Behandlungs-
zwang daran, ob das laufende Programm das Problem lösen kann (siehe auch Ullenboom 2016, Ab-
schnitt 7.5.5):
• Lösbares Problem
Auf ein lösbares Problem wird mit einer geprüften Ausnahme hingewiesen. Wird z. B. mit
einer FileNotFoundException ein fehlgeschlagener Leseversuch gemeldet, kann das Pro-
gramm den Benutzer nach einer alternativen Datei fragen.
• Unlösbares Problem
Kann das Problem vom laufenden Programm kaum behoben bzw. kompensiert werden, wird
auf den Behandlungszwang verzichtet. Wenn die JVM z. B. eine zur Fortsetzung des Pro-
gramms benötigte class-Datei nicht findet, wirft sie einen NoClassDefFoundError, auf den
man sich nicht vorbereiten muss.
Wer für eine selbst definierte Ausnahmeklasse (siehe Abschnitt 11.9) eine Basisklasse wählt und
damit über die Compiler-Kontrolle entscheidet, sollte sich ebenfalls an Ursache und Schwere des
Problems orientieren. Bei Bloch (2018, S. 297) findet sich die folgende Empfehlung:
To summarize, throw checked exceptions for recoverable conditions and unchecked exceptions
for programming errors. When in doubt, throw unchecked exceptions.
Aufgrund der eben referierten Empfehlungen zur Entscheidung zwischen geprüften und ungeprüf-
ten Ausnahmen sollte z. B. die von der API-Konvertierungsmethode Integer.parseInt() zu erwar-
tende NumberFormatException (im Paket java.lang) eine geprüfte Ausnahme sein, weil z. B. oft
eine aus externen Quellen stammende Zeichenfolge konvertiert werden muss (externe Ursache, lös-
554 Kapitel 11 Ausnahmebehandlung

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.

11.6 Ausnahmen in einer eigenen Methode werfen und ankündigen

11.6.1 Ausnahmen auslösen (throw), ankündigen (throws) und dokumentieren


Unsere eigenen Methoden und Konstruktoren müssen sich nicht auf das Abfangen von Ausnahmen
beschränken, die vom Laufzeitsystem oder von Bibliotheksmethoden stammen, sondern können
sich auch als „Werfer“ betätigen, um bei einer misslungenen Ausführung den Aufrufer mit Hilfe
eines Ausnahmeobjekts zu informieren.
Insbesondere sollten Methoden und Konstruktoren die übergebenen Parameterwerte routinemäßig
prüfen und ggf. die Ausführung durch das Werfen einer Ausnahme abbrechen (siehe Abschnitt
11.7). In der folgenden Variante unseres Beispielprogramms zur Fakultätsberechnung wird in der
Methode convertInput() ein Ausnahmeobjekt aus der Klasse IllegalArgumentException (im
Paket java.lang) geworfen, wenn der Aktualparameter entweder nicht interpretierbar ist, oder aber
die erfolgreiche Interpretation ein unzulässiges Fakultätsargument ergibt:
/**
* Converts the input string into an int-Value.
*
* @throws IllegalArgumentException if parsing the argument fails
* or the resulting value is not in [0 ... 170].
*/
static int convertInput(String instr) {
int arg;
try {
arg = Integer.parseInt(instr);
if (arg < 0 || arg > 170)
throw new IllegalArgumentException(
"Unzulässiges Argument (erlaubt: 0 bis 170): " + arg);
else
return arg;
} catch (NumberFormatException e) {
throw new IllegalArgumentException("Fehler beim Konvertieren: " + instr, e);
}
}
Zum Auslösen einer Ausnahme dient die throw-Anweisung. Hier ist nach dem Schlüsselwort
throw eine Referenz auf ein Ausnahmeobjekt anzugeben. Dieses Objekt wird in der Regel per new-
Operator mit nachfolgendem Konstruktor vor Ort erzeugt (siehe Beispiel).
Die meisten Ausnahmeklassen besitzen u. a. die folgenden Konstruktoren:
Abschnitt 11.6 Ausnahmen in einer eigenen Methode werfen und ankündigen 555

• einen parameterfreien Konstruktor


• einen Konstruktor mit einem String-Parameter für eine Fehlermeldung zur näheren Be-
schreibung der Ausnahme, die im Exception-Handler über die Methode getMessage()
abgerufen werden kann (vgl. Abschnitt 11.3.3)
• einen Konstruktor mit einem String-Parameter für eine Fehlermeldung und einem Throwa-
ble-Parameter für den Verweis auf ein ursprüngliches Ausnahmeobjekt, dessen Behandlung
zum Erstellen der aktuellen Ausnahme geführt hat (siehe den NumberFormatException -
catch-Block im Beispiel).
Mit der Fehlerbehebung beauftragte Personen erfahren eventuell nur den Namen der Ausnahme-
klasse, die Fehlermeldung und eventuell noch die Aufrufsequenz (siehe Abschnitt 11.3.3). Eine
möglichst informative Fehlermeldung mit Details zur Unfallursache (z. B. erlaubte Werte, fehler-
hafter Wert) kann daher eine große Hilfe sein (siehe den obigen Quellcode der Methode
convertInput()).
Viele catch-Blöcke betätigen sich als Informationsvermittler und werfen selbst eine Ausnahme, um
dem Aufrufer einen leichter verständlichen Unfallbericht zu liefern. Wird wie im folgenden Bei-
spiel
throw new IllegalArgumentException("Fehler beim Konvertieren: " + instr, e);
in die neue Ausnahme die Adresse der ursprünglichen aufgenommen, kann der Aufrufer über die
Methode getCause() Ursachenforschung betreiben (siehe unten).
Hat eine Ausnahmehandlung weder zur Lösung geführt, noch zusätzliche Informationen erbracht,
dann kann ein catch-Block das ursprüngliche Ausnahmeobjekt erneut werfen, um die Vorgänger in
der Aufrufersequenz davon in Kenntnis zu setzen.
Für die Benutzung einer Methode (durch andere Programmierer) ist es wichtig, dass alle von dieser
Methode zu erwartenden Ausnahmen dokumentiert werden, was in einem Dokumentationskom-
mentar geschehen sollte (siehe obigen Quellcode der Methode convertInput()).
Bei ausgelösten oder weitergeleiteten geprüften Ausnahmen (z. B. IOException) besteht der Com-
piler darauf, dass diese entweder behandelt oder deklariert werden. Zur Deklaration ist im Definiti-
onskopf eine throws-Klausel mit dem Namen der Ausnahmeklasse anzugeben, z. B.:1
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);
}
Durch Kommata getrennt können nach dem Schlüsselwort throws auch mehrere Ausnahmeklassen
deklariert werden.
Bei ungeprüften Ausnahmen (RuntimeException und Unterklassen, siehe Abschnitt 11.5) ist es
dem Programmierer freigestellt, ob er die in seiner Methode (direkt oder indirekt) ausgelösten, aber
nicht behandelten Ausnahmen deklarieren möchte, z. B.:
static int convertInput(String instr) throws IllegalArgumentException { ... }

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).

11.6.2 Compiler-Intelligenz beim erneuten Werfen von abgefangenen Ausnahmen


Seit Java 7 bietet der Compiler beim erneuten Werfen einer geprüften Ausnahme durch einen
catch-Block eine kleine Erleichterung, wenn mehrere Ausnahmetypen im Spiel sind. Eventuell
müssen Sie aber zum Lesen der folgenden Erklärung mehr Zeit aufwenden, als Sie jemals durch die
beschriebene Technik einsparen können. Im folgenden Beispiel1 sind von einem try-Block zwei
geprüfte Ausnahmen zu erwarten. Diese werden der Einfachheit halber (zur Vermeidung von Code-
Wiederholung) in einem catch-Block behandelt, der als Ausnahmetyp die Basisklasse Exception
angibt. Im catch-Block wird die abgefangene Ausnahme erneut geworfen:
class FirstException extends Exception { }
class SecondException extends Exception { }
. . .
static void rethrowException(String name) throws FirstException, SecondException {
try {
if (name.equals("First"))
throw new FirstException();
else
throw new SecondException();
} catch (Exception e) {
e.printStackTrace(System.out);
throw e;
}
}
Eigentlich müsste man daher in der throws - Klausel des Methodenkopfs die Ausnahmeklasse
Exception angeben. Seit Java 7 ist es erlaubt, stattdessen die beiden tatsächlich möglichen Aus-
nahmetypen anzugeben, sodass der Aufrufer präziser informiert wird. Der Compiler kann durch
eine Analyse des try-Blocks die Korrektheit der Angaben in der throws-Klausel verifizieren.
Ein älterer Java-Compiler würde hingegen den unbehandelten Ausnahmetyp Exception reklamie-
ren, und man müsste ...
• entweder im Methodenkopf den Ausnahmetyp Exception anmelden, was eine Informations-
reduktion zur Folge hätte,
• oder für die beiden Ausnahmetypen jeweils einen separaten catch-Block erstellen, was zu
einer Code-Wiederholung führen würde.

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;
}

11.7 Ausnahmen bei der Parameter-Validierung


Eine Methode sollte ihre Aktualparameterwerte validieren und auf Fehler mit einer Ausnahme rea-
gieren; dasselbe gilt für einen Konstruktor. Als Ausnahmeklasse eignet sich oft IllegalArgument-
Exception aus dem API-Paket java.lang. Nach Möglichkeit sollte das Problem präziser dargestellt
werden, z. B. durch Verwendung der folgenden Ausnahmeklassen:
• NullPointerException
Ein Aktualparameter mit dem Wert null ist das Problem.
• IndexOutOfBoundsException
Ein Indexwert liegt außerhalb des zulässigen Bereichs, der z. B. bei Arrays von 0 (inklusive)
bis length (exklusive) reicht.
Als Beispiel betrachten wir die Methode get() in der Klasse ArrayList<E> aus dem Java Collec-
tions Framework (vgl. Abschnitt 10.4). Sie verwendet zur Indexvalidierung die (seit Java 9 vorhan-
dene) statische Methode checkIndex() der Klasse Objects (im Paket java.util), die bei unpassen-
dem Parameterwert eine IndexOutOfBoundsException wirft:
public E get(int index) {
Objects.checkIndex(index, size);
return elementData(index);
}
In früheren Kursbeispielen haben wir aus didaktischen Gründen die noch unbekannte Ausnahmebe-
handlung vermieden. So liefert z. B. die Methode get() aus der im Abschnitt 8.1.3.1 entwickelten
Klasse SimpleList<E> bei unpassendem Parameterwert (Elementindex) die Rückgabe null:
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;
}
Mittlerweile kennen wir zwei Alternativen zum Rückgabewert null:
• Werfen einer IndexOutOfBoundsException (Analog zu ArrayList<E>)
Das Werfen einer Ausnahme sollte auf außergewöhnlichen Umständen beschränkt bleiben.
• Rückgabe vom Typ Optional<E>
Der im Abschnitt 11.4 vorgestellte Typ Optional<E> eröffnet die Möglichkeit, ein leeres
Objekt als Rückgabe zu liefern.
Bei der Entscheidung zwischen diesen beiden Optionen können die ArrayList<E> - Designer nicht
als Vorbild dienen, weil der Typ Optional<E> nach dem Typ ArrayList<E> in das Java-API auf-
genommen wurde. Trotzdem scheint die Kommunikation per Ausnahmeobjekt bei einem Aktualpa-
rameter mit ungültigem Indexwert überlegen zu sein, weil vermutlich ein Programmierfehler vor-
liegt, der auf nicht-ignorierbare Weise artikuliert und schleunigst repariert werden sollte.
Abschnitt 11.8 Vollständige Beschreibung der try-catch-finally - Ausführung 559

11.8 Vollständige Beschreibung der try-catch-finally - Ausführung


Im Rahmen einer try-catch-finally - Anweisung kann es in einem catch-Block und/oder im final-
ly-Block zu einer (weiteren) Ausnahme kommen. Weil an den Aufrufer der betroffenen Methode
nur eine unbehandelte Ausnahme gemeldet wird, erfährt er z. B. nur ...
• von der Sekundär-Ausnahme aus dem catch-Block
• oder von der Tertiär-Ausnahme aus dem finally-Block
Die Java-Sprachspezifikation erklärt für die try-catch-finally - Anweisung aufwändig und präzise,
wann es zu einer abrupten Beendigung der Anweisung kommt, und welcher Grund unter den diver-
sen Konstellationen an den Aufrufer gemeldet wird (Gosling et al. 2021, Abschnitt 14.20.2). Dabei
wird auch die return-Anweisung als abrupte Beendigung eines try-, catch- oder finally-Blocks
betrachtet.
Bei der Ausführung des try-Blocks sind folgende Fälle zu unterscheiden:
• Der try-Blocks endet normal.
Dann wird der finally-Block ausgeführt, wobei die folgenden Fälle zu unterscheiden sind:
o Der finally-Block endet normal. Dann endet auch die try-Anweisung normal.
o Der finally-Block endet abrupt aus dem Grund S (z.B. Werfen einer Ausnahme, re-
turn-Anweisung). Dann endet auch die try-Anweisung abrupt aus dem Grund S.
• Der try-Block endet mit dem Werfen der Ausnahme V, wobei die folgenden Fälle zu unter-
scheiden sind:
o Es existiert ein catch-Block zur Behandlung der Ausnahme V.
Dann wird der catch-Block ausgeführt, und es sind zwei Fälle zu unterscheiden:
▪ Der catch-Block endet normal.
Dann wird der finally-Block ausgeführt, und es sind zwei Fälle zu unter-
scheiden:
- Der finally-Block endet normal. Dann endet auch die try-Anweisung
normal, weil die Ausnahme V behandelt worden ist.
- Der finally-Block endet abrupt aus dem Grund S (z.B. Werfen einer
Ausnahme, return-Anweisung). Dann endet auch die try-Anweisung
abrupt aus dem Grund S. Statt des im try-Block geworfenen Aus-
nahmeobjekts V sieht der Aufrufer z. B. eine scheinbar ungestörte
Ausführung. Er erhält den Rückgabewert der return-Anweisung aus
dem finally-Block.
▪ Der catch-Block endet abrupt aus dem Grund R.
Dann wird der finally-Block ausgeführt, und es sind zwei Fälle zu unter-
scheiden:
- Der finally-Block endet normal. Dann endet die try-Anweisung ab-
rupt aus dem Grund R. Statt des im try-Block geworfenen Ausnah-
meobjekts V sieht der Aufrufer z. B. das im catch-Block geworfene
Ausnahmeobjekt.
- Der finally-Block endet abrupt aus dem Grund S. Dann endet auch
die try-Anweisung abrupt aus dem Grund S. Die im try-Block ge-
worfene Ausnahme V und der Grund R aus dem catch-Block sind
verworfen.
560 Kapitel 11 Ausnahmebehandlung

o Es existiert kein catch-Block zur Behandlung der Ausnahme.


Dann wird der finally-Block ausgeführt, und es sind zwei Fälle zu unterscheiden:
▪ Der finally-Block endet normal. Dann endet die try-Anweisung abrupt mit
der Ausnahme V.
▪ Der finally-Block endet abrupt aus dem Grund S (z.B. Werfen einer Aus-
nahme, return-Anweisung). Dann endet die try-Anweisung abrupt mit dem
Grund S, und das Ausnahmeobjekt V ist verworfen.
• Der try-Block endet abrupt aus dem Grund R.
Hier geht es um eine abrupte Beendigung, die nicht aus dem Werfen einer Ausnahme be-
steht. Wir ziehen nur die return-Anweisung in Betracht.
Dann wird der finally-Block ausgeführt, und es sind die folgenden Fälle zu unterscheiden:
o Der finally-Block endet normal. Dann endet die try-Anweisung abrupt aus dem
Grund R (also mit der return-Anweisung).
o Der finally-Block endet abrupt aus dem Grund S. Dann endet die try-Anweisung ab-
rupt aus dem Grund S, und der Grund R ist verworfen.
Von den zahlreichen Konstellationen wird im folgenden Programm eine halbwegs verblüffende
vorgeführt:
class Mex1 extends Exception {public Mex1(String s) {super(s);}}
class Mex2 extends Exception {public Mex2(String s, Throwable t) {super(s, t);}}

public class Prog {


static void m() throws Exception {
try {
System.out.println("try von m(), wirft die Ausnahme Mex1");
throw new Mex1("Mex1");
}
catch (Exception e) {
System.out.println("catch von m(), wirft die Ausnahme Mex2 mit Verweis auf " +
e.getMessage());
throw new Mex2("Mex2", e);
}
finally {
return;
}
}

public static void main(String[] args) {


try {
m();
} catch (Exception e) {
System.out.println("catch von main()");
}
System.out.println("\nmain() sieht einen scheinbar erfolgreichen m() - Aufruf.");
}
}
Von main() wird die Methode m() aufgerufen, die eine try-catch-finally - Anweisung enthält. Die
im try-Block geworfene Ausnahme vom Typ Mex1 wird in einem catch-Block abgefangen. Der
catch-Block wirft eine neue Ausnahme vom Typ Mex2 und verweist im Konstruktor auf die ur-
sprüngliche Ausnahme. Der finally-Block endet „abrupt“ mit einer return-Anweisung, die dem
Aufrufer eine scheinbar ungestörte Ausführung der Methode m() vorgaukelt:
try von m(), wirft die Ausnahme Mex1
catch von m(), wirft die Ausnahme Mex2 mit Verweis auf Mex1

main() sieht einen scheinbar erfolgreichen m() - Aufruf.


Abschnitt 11.9 Ausnahmen definieren 561

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).

11.9 Ausnahmen definieren


Mit Hilfe von Ausnahmeobjekten kann eine Methode beim Auftreten von Laufzeitfehlern die aufru-
fende Methode präzise über Ursachen und Begleitumstände informieren. Dabei müssen sich Soft-
ware-Entwickler nicht auf die im Java-API vorhandenen Ausnahmeklassen beschränken, sondern
können auch eigene Ausnahmentypen definieren. Das sollte allerdings zurückhaltend geschehen,
weil ungewohnte Ausnahmeklassen die Lesbarkeit des Quellcodes erschweren, und im Java-API
eigentlich Ausnahmeklassen für fast alle Fälle vorhanden sind. Wir haben im Manuskript schon
etliche API-Ausnahmeklassen kennengelernt, z. B.:
• NullPointerException
• IllegalArgumentException
• NumberFormatException
• IndexOutOfBoundsException
• ConcurrentModificationException (siehe Abschnitt 10.5)
• UnsupportedOperationException (siehe Abschnitt 10.2)
Trotzdem wird in diesem Abschnitt eine Eigenentwicklung namens BadFactorialArg-
Exception vorgeführt, die im Fakultäts-Beispielprogramm zur Fehlerkommunikation verwendet
werden kann:
public class BadFactorialArgException extends RuntimeException {
protected int error = -1, value = -1;
protected String instr;
public BadFactorialArgException(String message, String instr_,
int error_, int value_) {
super(message);
instr = instr_;
if (error_ == 1 || error_ == 2)
error = error_;
if (error_ == 3 && (value_ < 0 || value_ > 170)) {
error = error_;
value = value_;
}
}

public String getInstr() {return instr;}


public int getError() {return error;}
public int getValue() {return value;}
}
Der BadFactorialArgException() - Konstruktor verwendet seinen ersten Parameter in einem
Aufruf eines Basisklassenkonstruktors, sodass die String-Adresse in der von Throwable geerbten
Instanzvariablen detailMessage landet, die als Rückgabewert der ebenfalls von Throwable geerb-
ten Methode getMessage() dient.
Durch Verwendung der handgestrickten, aus RuntimeException abgeleiteten Ausnahmeklasse
BadFactorialArgException kann die Methode convertInput() beim Auftreten von irregu-
lären Argumenten neben einer Fehlermeldung noch weitere Informationen an aufrufende Methoden
übergeben:
562 Kapitel 11 Ausnahmebehandlung

• in der String-Instanzvariablen instr die zu konvertierende Zeichenfolge (abrufbar mit


getInstr())
• in der int-Instanzvariablen error einen numerischen Indikator für die Fehlerart (abrufbar
mit getError()):
o -1: unbekannter Fehler
o 1: String-Parameter ist null.
o 2: Fehler beim Konvertieren
o 3: Unzulässiger Wert (erlaubt: 0 bis 170)
• in der int-Instanzvariablen value das Konvertierungsergebnis (falls vorhanden, sonst -1;
abrufbar mit getValue())
Eine eigene Ausnahmeklasse passt gut zur der Strategie, Probleme aus diversen Teilschritten einer
Aufgabenbearbeitung an einer Stelle zusammenzuführen, wo gut über das weitere Vorgehen nach
einem Scheitern entschieden werden kann. An dieser Entscheidungsstelle muss dann nur ein Aus-
nahmeobjekt behandelt werden, das genügend Informationen über die Art des Problems enthält.
Durch die Wahl der Basisklasse RuntimeException ist eine unchecked exception entstanden. Im
Abschnitt 11.5.2 wurden einige Kriterien für die Entscheidung zwischen einer geprüften und einer
ungeprüften Ausnahme genannt. Oft fällt diese Entscheidung schwer, und viele Entwickler entzie-
hen sich der Mühe, indem sie generell ungeprüfte Ausnahmen verwenden. Das kann so falsch nicht
sein, weil andere Programmiersprachen (z. B. C#, C++, Kotlin, Scala) ausschließlich ungeprüfte
Ausnahmen kennen.
In der finalen Variante der Methode convertInput() läuft die Fehlerbehandlung komplett über
die BadFactorialArgException. Wir halten uns an die Empfehlung von Bloch (2018, S. 304),
ungeprüfte Ausnahme zu dokumentieren, aber nicht im Methodenkopf zu deklarieren (siehe Ab-
schnitt 11.6.1):
/**
* Converts the input string into an int-Value.
*
* @throws BadFactorialArgException if the argument is missing, parsing fails
* or the value is not in [0 ... 170]
*/
static int convertInput(String instr) {
int arg;
try {
if (instr == null)
throw new BadFactorialArgException("String-Parameter ist null.", "", 1, -1);
arg = Integer.parseInt(instr);
if (arg < 0 || arg > 170)
throw new BadFactorialArgException(
"Unzulässiger Wert (erlaubt: 0 bis 170)", instr, 3, arg);
else
return arg;
} catch (NumberFormatException e) {
throw new BadFactorialArgException("Fehler beim Konvertieren", instr, 2, -1);
}
}
Ein convertInput() - Aufrufer muss sich nicht um die BadFactorialArgException küm-
mern, sollte es aber tun, z. B.:
Abschnitt 11.10 Freigabe von Ressourcen 563

public static void main(String[] args) {


int argument = -1;
try {
argument = convertInput(args[0]);
double fakul = 1.0;
for (int i = 1; i <= argument; i++)
fakul = fakul * i;
System.out.println("Fakultät: " + fakul);
} catch(ArrayIndexOutOfBoundsException e) {
System.out.println("Kein Argument angegeben");
} catch (BadFactorialArgException e) {
System.out.println("Fehlerhaftes Argument: " + e.getMessage());
switch (e.getError()) {
case 2 : System.out.println("Zeichenfolge: \"" + e.getInstr() + "\"");
break;
case 3 : System.out.println("Wert: " + e.getValue());
break;
}
}
}
Im Beispiel kann der Kernalgorithmus ungestört durch Fehlerbehandlungen formuliert werden
argument = convertInput(args[0]);
double fakul = 1.0;
for (int i = 1; i <= argument; i++)
fakul = fakul * i;
System.out.println("Fakultät: " + fakul);
Die meisten potentiellen Ausnahmefehler sind berücksichtigt, und der Benutzer wird jeweils gut
informiert.

11.10 Freigabe von Ressourcen


Von einem Programm belegte externe Ressourcen wie Datei-, Netzwerk- oder Datenbankverbin-
dungen müssen möglichst früh wieder freigegeben werden, um den Benutzer und andere Program-
me möglichst wenig zu behindern. Außerdem muss sichergestellt werden, dass die Freigabe unter
allen Umständen erfolgt, insbesondere auch nach einem Ausnahmefehler.

11.10.1 Traditionelle Lösung per finally-Block


Vor Java 7 war der finally-Block einer try-catch-finally - Anweisung der ideale Ort zur Freigabe
von Ressourcen wie Datei-, Netzwerk- oder Datenbankverbindungen. Seit Java 7 bietet eine spezi-
elle try-Variante eine bequemere und zuverlässigere Lösung. Um den Fortschritt deutlich zu ma-
chen, betrachten wir zuerst die traditionelle, in vorhandenem Code noch oft anzutreffende Da-
teifreigabe per finally-Block mit close() - Aufruf. Im folgenden Beispiel wird (teilweise dem Kapi-
tel 14 vorgreifend) zur Demonstration der traditionellen Dateifreigabe eine statische Methode na-
mens mean() definiert, die mit Hilfe eines DataInputStream-Objekts aus einer Binärdatei dou-
ble-Zahlen liest und den Mittelwert daraus berechnet:
564 Kapitel 11 Ausnahmebehandlung

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();
};
}
}

public static void main(String args[]) {


mean("eingabe.dat");
}
}

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.

11.10.2 Try With Resources


Seit Java 7 lässt sich das Schließen der in einem try-Block benötigten Ressourcen automatisieren,
sofern die Klassen, die die Ressourcen repräsentieren, das Interface AutoCloseable im Paket ja-
va.lang implementieren. Um diese sehr empfehlenswerte Option zu nutzen, erzeugt man ein auto-
matisch zu schließendes Objekt in einem Ausdruck, der durch runde Klammern begrenzt zwischen
das Schlüsselwort try und den überwachten Block gesetzt wird. Das Beispiel aus dem letzten Ab-
schnitt kann so erheblich vereinfacht werden:
import java.io.*;

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();
}
}

public static void main(String args[]) {


mean("eingabe.dat");
}
}
Die finally-Klausel mit der close() - Anweisung ist überflüssig geworden, und die DataInput-
Stream-Variable ist nur im try-Block sichtbar.
Für einen try-Block lässt sich auch eine mehrelementige Ressourcenliste definieren, wobei zwi-
schen zwei Elemente ein Semikolon zu setzen ist.
try (DataInputStream dis = new DataInputStream(new FileInputStream(eingabe));
DataOutputStream dos = new DataOutputStream(new FileOutputStream(ausgabe))) {
. . .
}

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

static void mean(String eingabe) throws FileNotFoundException {


DataInputStream diso = new DataInputStream(new FileInputStream(eingabe));
try (DataInputStream dis = diso) {
double sum = 0.0;
int n = 0;
while (dis.available() > 0) {
n++;
sum += dis.readDouble();
}
System.out.println("Mittelwert: " + sum/n);
} catch (IOException ioe) {
ioe.printStackTrace();
}
}
Seit Java 9 kann die try-lokale Variable entfallen, sofern die Referenzvariable zur außerhalb defi-
nierten Ressource effektiv final ist:
static void mean(String eingabe) throws FileNotFoundException {
DataInputStream dis = new DataInputStream(new FileInputStream(eingabe));
try (dis) {
double sum = 0.0;
int n = 0;
while (dis.available() > 0) {
n++;
sum += dis.readDouble();
}
System.out.println("Mittelwert: " + sum/n);
} catch (IOException ioe) {ioe.printStackTrace();}
}
Die effektiv finale Ressourcen-Referenzvariable kann auch als Aktualparameter angeliefert werden:
static void mean(DataInputStream dis) {
try (dis) {
double sum = 0.0;
int n = 0;
while (dis.available() > 0) {
n++;
sum += dis.readDouble();
}
System.out.println("Mittelwert: " + sum/n);
} catch (IOException ioe) {ioe.printStackTrace();}
}

11.11 Übungsaufgaben zum Kapitel 11


1) Welche der folgenden Aussagen sind richtig bzw. falsch?
1. Eine Ausnahme aus der Klasse RuntimeException muss nicht behandelt werden.
2. In einem catch-Block kann das abgefangene Ausnahmeobjekt erneut geworfen werden.
3. Nach der erfolgreichen Ausführung eines try-Blocks, wird die Methode hinter der try-
catch-finally - Anweisung fortgesetzt.
4. In einem catch- oder finally-Block sind Methoden, die Ausnahmen werfen können, verbo-
ten.
5. Es ist auch eine try-finally - Anweisung (ohne catch-Block) erlaubt.

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.

4) Beim Rechnen mit Gleitkommazahlen produziert Java in kritischen Situationen üblicherweise


keine Ausnahmen, sondern operiert mit speziellen Werten wie Double.POSITIVE_INFINITY
oder Double.NaN. Dieses Verhalten ist sicher oft nützlich, kann aber eventuell die Fehlersuche
erschweren, wenn mit den speziellen Funktionswerten weitergerechnet wird, und am Ende eines
längeren Rechenwegs das Ergebnis Double.NaN steht. Im folgenden Beispiel wird eine Methode
namens duaLog() zur Berechnung des dualen Logarithmus (Logarithmus zur Basis 2) verwendet,
welche auf die statische Methode log() der Klasse Math im Paket java.lang zurückgreift und bei
ungeeigneten Argumenten ( 0) als Rückgabewert Double.NaN liefert.1
Quellcode Ausgabe
public class DuaLog { NaN
final static double LOG2 = Math.log(2);

public static double duaLog(double arg) {


return Math.log(arg) / LOG2;
}

public static void main(String[] args) {


double a = duaLog(8);
double b = duaLog(-1);
System.out.println(a*b);
}
}

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

final Button button = new Button("Klick");


. . .
button.setOnAction(event -> label.setText("Hallo JavaFX"));
Hier wird die Klickereignisbehandlung für einen Befehlsschalter (ein Objekt der Klasse Button)
mit Hilfe eines Lambda-Ausdrucks realisiert. Wie das Beispiel zeigt, werden Lambda-Ausdrücke
nicht nur im Rahmen von Java 8 - Strömen verwendet. Sie sind für diverse Aufgaben so attraktiv
und mittlerweile so verbreitet, dass wir uns mit diesem Ausdrucksmittel beschäftigen müssen.

12.1.1 Traditionelle und moderne Realisation von Funktionsobjekten


Ein weiteres Zitat von Horstmann (2014b) ist dazu geeignet, den Nutzen und die Bedeutung von
Lambda-Ausdrücken zu umreißen:
The single most important change in Java 8 enables faster, clearer coding and opens the door to
functional programming.
Wir beschreiben anschließend einige typische Aufgabenstellungen, deren traditionelle Lösung und
die seit Java 8 mögliche Lösungsalternative mit Lambda-Ausdrücken.

12.1.1.1 Funktionale Schnittstellen


In Java ist es oft erforderlich, eine Methode zu erstellen, die zu bestimmten Gelegenheiten aufgeru-
fen werden soll und daher bestimmte Voraussetzungen erfüllen muss, z. B.:
• Eine Ereignisbehandlungsmethode soll ausgeführt werden, wenn der Benutzer eines Pro-
gramms mit JavaFX-Bedienoberfläche auf eine Schaltfläche klickt. Dazu definiert man eine
Klasse, die das Interface EventHandler<ActionEvent> erfüllt, also eine entsprechende Me-
thode
public void handle(ActionEvent event)
besitzt, erzeugt ein Objekt aus dieser Klasse und registriert es bei der Schaltfläche.1
• Eine Methode soll in einem separaten Thread ausgeführt werden. Dazu definiert man eine
Klasse, die das Interface Runnable erfüllt, also eine entsprechende Methode
public void run()
besitzt, erzeugt ein Objekt aus dieser Klasse und übergibt es z. B. an den Konstruktor der
Klasse Thread (siehe Abschnitt 15.1.1).
• Eine Methode soll zum Vergleich von Objekten eines bestimmten Typs herangezogen wer-
den. Dazu definiert man eine Klasse, die das passend parametrisierte Interface Compara-
tor<T> erfüllt, also eine entsprechende Methode
public int compare(T o1, T o2)
besitzt, und übergibt ein Objekt aus dieser Klasse z. B. an eine Methode zum Sortieren eines
Arrays mit Elementen vom Typ T.
In allen Beispielen ist ein Interface beteiligt, das genau eine abstrakte Methode enthält, z. B.:

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

public interface EventHandler<ActionEvent> extends EventListener {


void handle(ActionEvent event);
}
Seit Java 8 spricht man hier von einem funktionalen Interface, weil es bei der funktionalen Pro-
grammierung eine zentrale Rolle spielt. Neben der einen abstrakten Methode können beliebig viele
Instanzmethoden mit default-Implementierung sowie statische Methoden vorhanden sein (vgl. Ab-
schnitt 9.2.3).
Wenn ein Interface eine abstrakte Methode deklariert, die eine öffentliche Methode von ja-
va.lang.Object überschreibt, dann scheitert das Kriterium für funktionale Schnittstellen nicht an der
zusätzlichen Existenz dieser abstrakten Methode, weil jede Klasse eine Implementation für diese
Methode besitzt (von Object oder einer anderen Klasse geerbt). Ein Beispiel für die beschriebene
Konstellation ist die Schnittstelle Comparator<T>, die die Object-Methode equals() explizit for-
dert:1
@FunctionalInterface
public interface Comparator<T> {
int compare(T o1, T o2);
boolean equals(Object obj);
. . .
}

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> {

boolean test(T t);

default Predicate<T> and(Predicate<? super T> other) {


Objects.requireNonNull(other);
return (t) -> test(t) && other.test(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

default Predicate<T> negate() {


return (t) -> !test(t);
}

default Predicate<T> or(Predicate<? super T> other) {


Objects.requireNonNull(other);
return (t) -> test(t) || other.test(t);
}

static <T> Predicate<T> isEqual(Object targetRef) {


return (null == targetRef)
? Objects::isNull
: object -> targetRef.equals(object);
}
}
Das Interface enthält (unschädlich für den Begriff der funktionalen Schnittstelle) außerdem noch
Instanzmethoden mit default-Implementation sowie eine statische Methode.
Bei älteren Standardbibliothekschnittstellen nach dem funktionalen Muster wurde darauf verzichtet,
die Annotation @FunctionalInterface nachträglich einzufügen. Trotzdem sind Lambda-Ausdrücke
auch mit diesen Schnittstellen kompatibel.
Ein Objekt, das eine funktionale Schnittstelle implementiert, wird auch als Funktionsobjekt be-
zeichnet. Durch die Übergabe eines solchen Objekts als Methoden- oder Konstruktorparameter wird
eine Funktionalität in eine Methode oder Klasse injiziert. Dabei wird das sogenannte Strategie-
Entwurfsmuster realisiert.1
Im zeitgemäßen Java-Programmierstil wird diese Option zur Verhaltenskonfiguration oft als Alter-
native zur Definition von abgeleiteten Klassen verwendet (siehe z. B. Bloch 2018, S. 199). Die
Funktionalität einer Klasse kann modifiziert werden, indem einem Feld mit dem Datentyp einer
funktionalen Schnittstelle ein Funktionsobjekt zugewiesen wird. Allerdings wird die Vererbung, die
ja eine von drei Kernmerkmalen der objektorientierten Programmierung ist (vgl. Abschnitt 4.1.1),
von den Funktionsobjekten nicht überflüssig gemacht. Die Definition einer abgeleiteten Klasse ist
z. B. empfehlenswert, um eine Basisklasse um zusätzliche Member zu erweitern.
In den folgenden Abschnitten werden Optionen zur Definition von Funktionsobjekten beschrieben.

12.1.1.2 Anonyme Klassen


Die den Beispielen von Abschnitt 12.1.1.1 gemeinsame Aufgabenstellung, Funktionalität an Me-
thoden oder Klassen zu übergeben, wird in Java bis zur Version 7 häufig mit Hilfe von sogenannten
anonymen Klassen realisiert. Dabei wird die benötigte Funktionalität an Ort und Stelle realisiert,
ohne eine anderenorts (eventuell sogar in einer eigenen Datei) definierte Klasse zu benötigen. Ano-
nyme Klassen werden u. a. bei der Ereignisbehandlung in GUI-Programmen verwendet. Das gilt
auch für JavaFX, was beim Verzicht auf die deklarativen Oberflächengestaltung per FXML beson-
ders deutlich wird. Im folgenden Programm wird per Mausklick ein Text zur Anzeige gebracht:

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;

public class EventHandlerAnon extends Application {


@Override
public void start(Stage primaryStage) {
final Label label = new Label();
final Button button = new Button("Klick");

button.setOnAction(new EventHandler<ActionEvent>() {
@Override
public void handle(ActionEvent event) {
label.setText("Hallo JavaFX");
}
});

final VBox root = new VBox(5);


root.setAlignment(Pos.CENTER);
root.getChildren().addAll(button, label);
final Scene scene = new Scene(root, 200, 100);
primaryStage.setScene(scene);
primaryStage.show();
}

public static void main(String[] args) {


launch(args);
}
}
Mit dem Aufbau dieses Quellcodes werden wir uns im Kapitel 13 näher beschäftigen. Momentan
konzentrieren wir uns darauf, wie für den Befehlsschalter (ein Objekt aus der Klasse Button) die
Klickbehandlung realisiert wird:
button.setOnAction(new EventHandler<ActionEvent>() {
@Override
public void handle(ActionEvent event) {
label.setText("Hallo JavaFX");
}
});
Durch einen Aufruf der Button-Methode setOnAction() wird zur Behandlung von Klickereignissen
ein Objekt registriert, dessen Klasse die Schnittstelle EventHandler<ActionEvent> erfüllt. Dabei
wird nicht nur das Objekt dynamisch erzeugt, sondern eine komplette Klasse an Ort und Stelle defi-
574 Kapitel 12 Funktionales Programmieren

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";

static ToIntFunction<String> stoiAnonS = new ToIntFunction<>() {


@Override
public int applyAsInt(String s) {
return (statEnv+s).length();
}
};

void instMeth() {
String methEnv = "Meth";

ToIntFunction<String> stoiAnonIM = new ToIntFunction<>() {


@Override
public int applyAsInt(String s) {
return (statEnv+instEnv+methEnv+s).length();
}
};

System.out.println("\nAnon. Kl. in umgebender Instanzmethode: "+


ls.stream().mapToInt(stoiAnonIM).sum());
}

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

public static void main(String[] args) {


String methEnv = "Meth";

ToIntFunction<String> stoiAnonSM = new ToIntFunction<>() {


@Override
public int applyAsInt(String s) {
return (statEnv+methEnv+s).length();
}
};

System.out.println("Anon Kl. mit statischem Kontext: "+


ls.stream().mapToInt(stoiAnonS).sum());
System.out.println("\nAnon. Kl. in umgebender stat. Methode: "+
ls.stream().mapToInt(stoiAnonSM).sum());
Umgebungen u = new Umgebungen();
u.instMeth();
}
}

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.

12.1.1.3 Compiler-Kompetenz statt Boilerplate-Code


Mit Boilerplate-Code sind Syntaxfragmente gemeint, die sich häufig in weitgehend identischer
Form wiederholen. Infolgedessen hält man früher oder später nach einer Möglichkeit Ausschau, das
manuelle Verfassen von solchen Codesegmenten zu vermeiden. Die seit Java 8 mögliche Realisie-
rung einer Ereignisbehandlungsmethode durch einen Lambda-Ausdruck enthält im Vergleich zur
traditionellen Lösung durch eine anonyme Klasse deutlich weniger monoton und umständlich zu
wiederholenden Boilerplate-Code z. B.:
button.setOnAction(event -> label.setText("Hallo JavaFX"));
Hinter den Kulissen bleibt im Wesentlichen alles beim Alten, wobei der Compiler viele Routinear-
beiten übernimmt:
• Er kennt den Interface-Parametertyp EventHandler<ActionEvent> von setOnAction() und
akzeptiert einen Lambda-Ausdruck, der die erforderliche Methode handle() realisiert, so-
dass sich eine passende anonyme Klasse erstellen lässt.
• Der im Lambda-Ausdruck vor dem Pfeil (->) angegebene Parameter muss vom passenden
Typ sein. Man kann jedoch auf eine Typangabe verzichten, wobei der Compiler den Para-
meterdatentyp aus der Interface-Definition übernimmt.
Abschnitt 12.1 Lambda-Ausdrücke 577

• 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.:

• Weil die Typen EventHandler<ActionEvent> und ActionEvent im Unterschied zur Ver-


wendung einer anonymen Klasse (siehe Abschnitt 12.1.1.2) im Quellcode nicht mehr auf-
tauchen, sind keine Import-Deklarationen erforderlich.
• Wenn das GUI-Framework später (nach einem Mausklick auf den Schalter) die
EventHandler<ActionEvent> - Methode handle() aufruft, wird der Code im Lambda-
Ausdruck ausgeführt.
Ab Java 8 gilt generell: Wo der Compiler ein Objekt vom Datentyp einer funktionalen Schnittstelle
erwartet, ist ein Lambda-Ausdruck passender Bauart erlaubt. Das zu Beginn von Kapitel 12 vorge-
stellte Beispiel
s -> s.length() >= 5
eignet sich z. B. als Parameter für die Methode filter() im Interface Stream<String>:
List<String> als = List.of("Rudolf", "Emma", "Otto", "Agnes", "Frank");
Stream<String> str = als.stream();
str = str.filter(s -> s.length() >= 5);
In diesem Beispiel wird die im Abschnitt 10.4.1 vorgestellte statische und generische Methode of()
aus dem Interface List<E> dazu verwendet, ein List<String> - Objekt zu erstellen. Aus diesem
Objekt entsteht über die Methode stream() aus dem Interface Collection<E> (siehe Abschnitt 10.3)
ein Objekt der Klasse Stream<String>. Der Stream<String> - Methode filter() wird ein Lambda-
Ausdruck übergeben, der auf jedes String-Element im Strom angewandt wird. Es entsteht ein neuer
Strom aus den Elementen des alten Stroms mit mindestens 5 Zeichen.
Die Methode filter() erwartet einen Parameter vom Interface-Typ Predicate<String> aus dem Pa-
ket java.util.function (siehe Quellcode im Abschnitt 12.1.1.1). Man kann den Lambda-Ausdruck
einer Referenzvariablen von diesem Typ zuweisen
Predicate<String> ps = s -> s.length() >= 5;
und die Variable anschließend als filter() - Parameter verwenden, z. B.:
str = str.filter(ps);
Bei einem mehrfach benötigten Lambda-Ausdruck ist die Verwendung einer Variablen zu empfeh-
len, weil eine Code-Wiederholung gegen das DRY-Prinzip (Don‘t Repeat Yourself) verstoßen wür-
de.
Man darf sich vorstellen, dass der Compiler bei der Übersetzung der folgenden Zuweisung
str = str.filter(s -> s.length() >= 5);
aus dem Lambda-Ausdruck eine anonyme Klasse konstruiert:
str = str.filter(new Predicate<String>() {
public boolean test(String s) {
return s.length() >= 5;
}
});

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).

12.1.2 Definition von Lambda-Ausdrücken


Um mit der Syntax des Lambda-Ausdrucks vertraut zu werden, verschaffen wir uns zunächst einen
Überblick mit Hilfe eines Syntaxdiagramms:
Lambda-Ausdruck
Ausdruck
Formalparameterliste -> {
Anweisungsblock

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;};

Tester<String> tester = s ->


{if (s.length() == 13) throw new Exception(); return s.length() >= 5;};
}
}
werden die beiden Ausnahmen werfenden Lambda-Ausdrücke vom Compiler akzeptiert:
• Im Lambda-Ausdruck vom Typ Predicate<String> (zur Definition siehe Abschnitt
12.1.1.1) wird eine ungeprüfte Ausnahme (aus der Klasse RuntimeException, vgl. Ab-
schnitt 11.5.2) geworfen, was generell erlaubt ist.
• Im Lambda-Ausdruck vom Typ Tester<String> wird eine geprüfte Ausnahme geworfen,
die im implementierten Interface angemeldet wird (siehe Definition der Methode test()).
Einem das Interface Predicate<String> implementierenden Lambda-Ausdruck ist es hingegen ver-
boten, eine ungeprüfte Ausnahme zu werfen:

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());
}

public static void main(String[] args) {


LambdaScoping ls = new LambdaScoping();
ls.prot(); ls.prot(); ls.prot();
}
}

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 Methoden- und Konstruktorreferenzen


Man glaubt es kaum, doch in manchen Situationen bietet Java seit der Version 8 zur Erstellung von
Funktionsobjekten für die Lambda-Syntax noch prägnantere Alternativen. Dadurch sind allerdings
für Einsteiger noch mehr Syntaxvarianten zu lernen.

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);
}

public static void main(String[] args) {


TreeSet<String> tssc = new TreeSet<>(StatMethRef::compare);
tssc.add("Werner"); tssc.add("Ludwig"); tssc.add("Otto");
System.out.println(tssc);
}
}

Der TreeSet<String> - Konstruktor erwartet ein Funktionsobjekt, das die Schnittstelle


Comparator<? super String>
erfüllt. Dazu ist eine Methode mit dem folgenden Definitionskopf erforderlich:
int compare (? super String s1, ? super String s2)
Statt wie im Abschnitt 10.6.5 eine Klasse zu definieren, die das Interface erfüllt, wird eine statische
Methode von der geforderten Bauart definiert und per Methodenreferenz an den Konstruktor über-
geben.
Weitere Details zu Methodenreferenzen sind z. B. bei Horstmann (2014b) zu finden.

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

12.2.1 Elementare Begriffe und Beispiel


Neben den Lambda-Ausdrücken haben die Stream<T> - Typen im Paket java.util.stream unter
den mit Java 8 eingeführten Neuerungen die größte Bedeutung. Stromobjekte erlauben datenbankar-
tige Abfrage- und/oder Verarbeitungsoperationen für alle von einer Datenquelle (z. B. von einer
Kollektion oder einer Datei) gelieferten Elemente.
Dabei ist eine deklarative Programmierung möglich, und das explizite Iterieren über die Stromele-
mente bei ständiger Aktualisierung von Variablen mit Zwischenergebnissen wird in die Tiefen der
Standardbibliothek verlagert. Man kann z. B. (analog zu einer Datenbankabfrage per SQL-Abfrage
mit SELECT-Spaltenwahl, WHERE-Bedingung und AVG-Funktion) für eine Serie von Kontoob-
jekten den mittleren Stand für die Konten eines bestimmten Typs ermitteln, ohne sich um Details
bei der Iteration über die Elemente und bei der Fallauswahl kümmern zu müssen. Anwendungspro-
grammierer können sich auf das Was konzentrieren und viele Wie-Implementierungsdetails der
Standardbibliothek überlassen.
586 Kapitel 12 Funktionales Programmieren

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

Insgesamt wird das sogenannte Filter-Map-Reduce - Muster realisiert.


Ein Stromobjekt ist kein Container, sondern eine Station in einer Verarbeitungskette für die Daten
in einer Sequenz. Außerdem dürfen die in Java 8 eingeführten Stromtypen zur Massenabfertigung
von Elementen in Sequenzen nicht mit den viel älteren I/O - Stream - Klassen verwechselt (z. B.
InputStream and OutputStream, siehe Kapitel 14), die schon in Java 1.0 vorhanden waren.
Als Datenquellen für ein Stromobjekt kommen z. B. in Frage:
• eine Kollektion
• ein Array
• eine Methode zum Generieren von (potentiell unendlich vielen) Werten
• ein anderer Strom
Auf diese Daten kann ein Stromobjekt serielle und parallele Aggregatoperationen anwenden, wo-
bei ein neues Stromobjekt oder ein Endergebnis entsteht. Die Besonderheit von Aggregatoperatio-
nen besteht darin, dass sie auf den Strom im Ganzen wirken, also auf alle Elemente der Quelle.
In der Regel werden mehrere Stromoperationen hintereinander gestellt, sodass eine Pipeline ent-
steht (siehe Beispiel). Diese Pipeline ...
• startet mit einer Datenquelle,
• durchläuft beliebig viele intermediäre Operationen (eventuell aber auch keine)
• und endet mit einer terminalen Operation, die ein Endergebnis produziert (z. B. eine Zahl
oder eine Kollektion)
Liegt das Ergebnis vor, dann ist die Pipeline mit ihren Stromobjekten verbraucht und kann nicht
erneut durchlaufen werden. Ein Versuch führt zum Laufzeitfehler:
java.lang.IllegalStateException: stream has already been operated upon or closed

12.2.2 Externe versus interne Iteration


Bei der traditionellen externen Iteration über die Elemente einer Kollektion (z. B. per for-Schleife)
...
• muss der Programmierer Schritt für Schritt festlegen, wie vorzugehen ist, sodass Zeitauf-
wand und Fehlergefahr gegeben sind,
• ist eine eventuell mögliche Parallelisierung schwer zu realisieren, sodass sie meist unter-
bleibt.
588 Kapitel 12 Funktionales Programmieren

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 {

public static void main(String[] args) {


int[] daten = {2, 4, 5, 7, 8, 11};

// 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.3 Eigenschaften von Strömen

12.2.3.1 Datentyp der Elemente


Java besitzt seit der Version 8 im Paket java.util.stream die folgenden Schnittstellen, die das Ver-
halten von Strom-Objekten definieren:
• Die generische Schnittstelle Stream<T> für Elemente mit einem Referenztyp
• Die Schnittstellen IntStream, LongStream und DoubleStream für Elemente vom primiti-
ven Typ int, long bzw. double
Die vier Schnittstellen verfügen über analoge Methoden, doch bestehen auch einige Unterschiede.
Die Implementationen der Schnittstellen mit einem primitiven Elementtyp arbeiten performanter als
vergleichbare Ströme mit einer Verpackungsklasse als Elementtyp (Stream<Integer>,
Stream<Long>, Stream<Double>). Außerdem enthalten die drei Schnittstellen für Ströme mit
primitiven Elementen bequeme Methoden, die für einen Strom statistische Kennwerte wie die
Summe oder den Mittelwert durch einen einfachen Aufruf liefern (z. B. sum() oder average() siehe
Abschnitt 12.2.5.4.3).

12.2.3.2 Sequentiell oder parallel


Die Ströme ...
• beschränken sich entweder auf die sequentielle Ausführung von Operationen in einem einzi-
gen Thread
• oder versuchen, eine Operation nach Möglichkeit in Teilaufgaben zu zerlegen, die parallel in
mehreren Threads ausgeführt werden können, um später die Ergebnisse zusammenzuführen.
Weil die CPUs in moderner Computer-Hardware (ob Desktop-System, Server oder Smartphone)
mehrere (virtuelle) Prozessorkerne besitzen (ca. 2 bis 16), sind parallele Ströme von hoher Rele-
vanz für die Entwicklung von leistungsfähigen Anwendungen. Die für Multithreading typische
Komplexität mit dem Risiko von Programmierfehlern (siehe Kapitel 15) lässt sich mit Hilfe der
Java 8 - Ströme manchmal vermeiden, weil die kritischen Aufgaben von der Standardbibliothek
übernommen werden.
Bei parallelen Stromoperationen kommt im Hintergrund das Fork-Join - Framework zum Einsatz,
das Sie im Abschnitt 15.5 kennenlernen werden.
Für den Programmierer verbleibt auf jeden Fall die Ermessensentscheidung für oder gegen den Ein-
satz der Parallelisierung. Mehrere Threads zu starten, zu koordinieren und deren Ergebnisse zu-
sammen zu führen, verursacht einen unvermeidlichen Aufwand, der sich bei kleinen Problemen
nicht lohnt.
Zudem kann die vollautomatische Parallelisierung mit Hilfe von Java 8 - Strömen bei allzu optimis-
tischer (unbedachter) Anwendung scheitern und zu Programmen führen, die nicht nur eine miserab-
len Performanz besitzen, sondern eventuell sogar fehlerhafte Ergebnisse liefern (Bloch 2018,
222ff). Im Abschnitt 12.2.5.4.2 wird ein Beispiel für die erfolgreiche Anwendung von paralleler
Stromverarbeitung präsentiert.

12.2.4 Erstellung von Stromobjekten


In diesem Abschnitt werden verschiedene Optionen zur Erstellung von Stromobjekten vorgestellt.

12.2.4.1 Stromobjekt aus einer Kollektion (stream, parallelStream)


Im Interface Collection<E> (siehe Abschnitt 10.3) sind zum Erstellen eines (parallelen) Stroms die
Instanzmethoden stream() und parallelStream() vorhanden (mit default-Implementationen), z. B.:
590 Kapitel 12 Funktionales Programmieren

List<String> als = List.of("Rudolf","Emma","Otto","Agnes","Kurt");


Stream<String> sos = als.stream();
Stream<String> psos = als.parallelStream();
In diesem Beispiel wird zunächst die im Abschnitt 10.4.1 vorgestellte statische Fabrikmethode of()
der Schnittstelle List<E> dazu verwendet, eine List<String> - Kollektion zu erstellen:
public static <T> List<T> asList(T... a)

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);

12.2.4.3 Stromobjekte mit einer Sequenz ganzer Zahlen (range, rangeClosed)


In den Schnittstellen IntStream und LongStream ermöglichen die statischen Methoden range()
und rangeClosed() das bequeme Erstellen von Strömen bestehend aus einer Sequenz mit ganzen
Zahlen von einem Start- bis zu einem Endwert. Der einzige Unterschied zwischen den beiden Me-
thoden besteht darin, dass der Endwert bei range() ausgeschlossen und bei rangeClosed() einge-
schlossen ist, was im folgenden Programm demonstriert wird:
Quellcode Ausgabe
import java.util.stream.IntStream; Summe: 3
Summe: 6
class Prog {
public static void main(String[] args) {
long summe = IntStream.range(1, 3).sum();
System.out.println("Summe: " + summe);

summe = IntStream.rangeClosed(1, 3).sum();


System.out.println("Summe: " + summe);
}
}

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).

12.2.4.4 Unendliche Ströme (iterate, generate)


Die statischen Methoden iterate() und generate() in den Stromschnittstellen können einen endlosen
Strom produzieren.
An die folgenden iterate() - Überladungen
Abschnitt 12.2 Ströme 591

static <T> Stream<T> iterate(T seed, UnaryOperator<T> f)


static IntStream iterate (int seed, IntUnaryOperator f)
static LongStream iterate(long seed, LongUnaryOperator f)
static DoubleStream iterate (double seed, DoubleUnaryOperator f)
übergibt man einen Startwert mit dem Parameternamen seed sowie eine Funktion mit dem Parame-
ternamen f, die durch iterative Anwendung die Stromelemente produziert:
seed, f(seed), f(f(seed)), …
Im folgenden Beispiel resultiert ein Objekt vom Typ IntStream mit den Zweierpotenzen als Ele-
menten:
IntStream ins = IntStream.iterate(1, i -> 2*i).limit(11);
Per limit() wird der Strom auf die ersten 11 Elemente (20 bis 210) beschränkt (vgl. Abschnitt
12.2.5.3.1).
In der Schnittstelle IntStream erwartet die iterate() - Methode als zweiten Parameter ein Objekt
vom Typ IntUnaryOperator. Es beherrscht die Methode applyAsInt(), die für einen int-wertigen
Parameter eine Rückgabe vom selben Typ liefert. Im Beispiel wird der IntUnaryOperator per
Lambda-Ausdruck implementiert.
Seit Java 9 ist eine iterate() - Überladung mit drei Parametern verfügbar, z. B. im Interface
IntStream:
static IntStream iterate(int seed, IntPredicate hasNext, IntUnaryOperator next)
Im Vergleich zur oben beschriebenen Überladung wird über das Parameterobjekt hasNext eine Fort-
setzungsbedingung geprüft und bei negativem Ergebnis die Produktion von Stromelementen einge-
stellt.
Eine einfache Anwendung von generate() besteht darin, einen konstanten Strom mit Einsen zu pro-
duzieren, z. B.:
IntStream ins = IntStream.generate(() -> 1).limit(100);

12.2.4.5 Sonstige Erstellungsmethoden


In der Standardbibliothek sind etliche Methoden in der Lage, ein Stream-Objekt abzuliefern. Ein
Objekt der Klasse Random im Paket java.util beherrscht die Methoden ints(), longs() und
doubles() in jeweils mehreren Überladungen zur Produktion eines IntStream-, LongStream- oder
DoubleStream-Objekts mit Pseudozufallszahlen, z. B.:
public IntStream ints(long streamSize, int origin, int bound)
Das folgende Programm produziert 10 pseudozufällige int-Werte von 1 (inklusive) bis 10 (exklusi-
ve):
Quellcode Ausgabe
import java.util.Random; 1
import java.util.stream.IntStream; 9
1
class Prog { 7
7
public static void main(String[] args) { 9
Random ran = new Random(); 6
IntStream intStr = ran.ints(10, 1, 10); 5
intStr.forEach(System.out::println); 4
} 6
}
592 Kapitel 12 Funktionales Programmieren

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();
}
}
}

Die im Beispiel verwendete try-with-resources - Anweisung wurde im Abschnitt 11.10.2 vorge-


stellt.

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).

12.2.5.1 Intermediäre und terminale Stromoperationen


Die in den Stromschnittstellen (Stream<T>, IntStream, LongStream, DoubleStream) definierten
Methoden (Stromoperationen) lassen sich in zwei Kategorien einteilen:
• Intermediäre Operationen
Intermediäre Operationen liefern ein neues Stromobjekt als Rückgabe, sodass sie hinterei-
nander gekoppelt werden können. Wichtige Beispiele sind:
o filter()
Im resultierenden Strom sind nur noch die Elemente enthalten, die eine Bedingung
erfüllen (siehe Abschnitt 12.2.5.3.1).
o map()
Die Elemente des neuen Stroms entstehen durch elementweise Abbildung der Ele-
mente des alten Stroms, wobei sich auch der Elementtyp ändern kann (siehe Ab-
schnitt 12.2.5.3.3).
o sorted()
Der neue Strom entsteht aus dem alten durch das Sortieren der Elemente (siehe Ab-
schnitt 12.2.5.3.4).
o distinct()
Der neue Strom entsteht aus dem alten durch das Entfernen von Dubletten (siehe
Abschnitt 12.2.5.3.1).
Die in einer Pipeline hintereinander gekoppelten intermediären Operationen verbleiben in
Wartestellung, bis eine terminale Operation ausgeführt wird. Dann laufen alle Operationen
Abschnitt 12.2 Ströme 593

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.

12.2.5.2 Faulheit ist nicht immer dumm


Intermediäre Operationen werden erst dann ausgeführt, wenn es sich nicht weiter aufschieben lässt,
weil für die zugehörige Pipeline eine terminale Operation angefordert wurde. Dank dieser als lazy
(dt.: faul) bezeichneten Arbeitsweise sind folgende Optimierungen möglich:
• Ausführung von mehreren Operationen bei einer Datenpassage
Nach Möglichkeit werden mehrere Operationen bei einer einzigen Datenpassage erledigt.
Das spart Zeit im Vergleich zu mehreren, nacheinander ausgeführten Iterationen.
• Einschränkung von Operationen auf tatsächlich betroffene Elemente
Wenn z. B. eine spätere Operation den Strom auf die ersten 10 Elemente begrenzt, werden
auch die früheren Operationen (z. B. Abbildungen) nur für die ersten 10 Elemente ausge-
führt.
Im folgenden Beispielprogramm (nach einer Idee von Urma 2014) soll ausgehend von einer Liste
mit Namen eine neue Liste erstellt werden, die die beiden ersten Namen mit Mindestlänge 5 in
Großbuchstaben enthält.
594 Kapitel 12 Funktionales Programmieren

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);
}
}

Die Ausgabe zeigt,


• dass für die Fälle mit positivem Filterergebnis (Rudolf, Agnes) die Operationen filter() und
map() gemeinsam (bei einer Datenpassage) ausgeführt wurde,
• dass für die Fälle mit negativem Filterergebnis (Emma, Otto) keine Abbildung vorgenom-
men wurde,
• dass nach dem Vorliegen von zwei positiven Fällen keine weiteren (überflüssigen) Operati-
onen mehr ausgeführt wurden (für Kurt, Walter).
Zum Erstellen einer Kollektion vom Typ List<String> dient die terminale Operation collect() (sie-
he Abschnitt 12.2.5.4.4).

12.2.5.3 Intermediäre Operationen

12.2.5.3.1 Filtern der Elemente (filter, distinct)


Die Stream<T> - Operation filter() liefert einen neuen Strom bestehend aus allen Elementen des
alten Stroms, die einen Test bestanden haben. Sie benötigt als Parameter ein Funktionsobjekt vom
Interface-Typ Predicate<? super T>, das eine Methode namens test() mit einem booleschen Rück-
gabewert zur Beurteilung eines einzelnen Stromelements beherrscht:
public boolean test(? super T value)
Der Konkretheit halber betrachten wir anschließend das Interface Stream<String>. Hier verlangt
die filter() - Methode ein Parameterobjekt vom Typ Predicate<? super String>:
public Stream<String> filter(Predicate<? super String> predicate)
Die Verwendung des gebundenen Wildcard-Datentyps (vgl. Abschnitt 8.3.1.2) für den Parameter
stellt eine Liberalisierung im Vergleich zum Datentyp Predicate<String> dar. Neben einer test() -
Methode mit dem Parametertyp String sind auch Methoden mit einem generelleren Parametertyp
erlaubt.
Im folgenden Programm werden aus einem Strom vom Typ Stream<String> alle Elemente mit
genau vier Zeichen in einen neuen Strom vom selben Typ geleitet. Anschließend wird mit der ter-
minalen Operation count() (siehe Abschnitt 12.2.5.4.3) die Anzahl der Elemente im neuen Strom
ermittelt.
Abschnitt 12.2 Ströme 595

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.

12.2.5.3.3 Elementweise Abbildung (map, mapToInt)


Mit der generischen Methode map() aus dem Interface Stream<T> gewinnt man einen neuen
Strom mit Funktionswerten, die aus den Urbildern im alten Strom durch eine elementweise Abbil-
dung entstehen:
public <R> Stream<R> map(Function<? super T, ? extends R> mapper)
Das für die Abbildung zuständige Parameterobjekt muss das generische funktionale Interface
Function<T,R> aus dem Paket java.util.function implementieren, das die folgende Methode mit
einem Parameter vom Typ T und einer Rückgabe vom Typ R verlangt:
public R apply(T t)
In der Definition der generischen Methode map() steht R für den Elementtyp des Rückgabestroms,
und T steht für den Elementtyp des angesprochenen Stroms (Typ Stream<T>). Für das map() -
Parameterobjekt ist es in Ordnung, wenn die von ihm beherrschte apply() - Methode ...
• den Parameter T oder einen generelleren Typ akzeptiert,
• eine Rückgabe vom Typ R oder einem spezielleren Typ liefert.
Im folgenden Beispiel entsteht aus einem Strom mit Elementen vom Typ String ein neuer Strom
mit Elementen vom Typ Integer, der für jedes Element des alten Stroms die Anzahl der Zeichen
enthält, wobei das map() - Parameterobjekt auf einer Methodenreferenz basiert (siehe Abschnitt
12.1.3.1). Für die Ausgabe der String-Längen sorgt die terminale Operation forEach() (siehe Ab-
schnitt 12.2.5.4.1).
Quellcode Ausgabe
import java.util.stream.Stream; 6 4 4 5 4

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

12.2.5.3.4 Sortieren (sorted)


Mit der Methode sorted() aus dem Interface Stream<T> gewinnt man einen neuen Strom mit den
gemäß ihrer natürlichen Ordnung sortierten Elementen des angesprochenen Stroms, sofern der Da-
tentyp der Elemente das Interface Comparable<T> erfüllt.
Im folgenden Beispiel entsteht aus einem Strom mit Elementen vom Typ String ein aufsteigend
sortierter Strom mit denselben Elementen:
Quellcode Ausgabe
import java.util.stream.Stream Agnes
Emma
class Sorted { Kurt
public static void main(String[] args) { Otto
Stream.of("Rudolf", "Emma", "Otto", "Agnes", "Kurt") Rudolf
.sorted()
.forEach(System.out::println);
}
}

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);
}
}

12.2.5.3.5 Zwischenberichte (peek)


Veranlasst man eine Ausgabe der Stromelemente über die im Abschnitt 12.2.5.4.1 zu beschreibende
terminale Stromoperation forEach(), dann ist die gesamte Pipeline anschließend verbraucht und
inoperabel. Weil dieses Verhalten die Fehlersuche erschwert, stellen die Stromschnittstellen für
Diagnosezwecke die Operation peek() zur Verfügung, z. B.:
public Stream<T> peek(Consumer<? super T> action)
Man erhält als Rückgabe einen Strom mit den Elementen des angesprochenen Stroms und kann
außerdem über das Parameterobjekt eine elementweise durchzuführende Aktion vereinbaren (z. B.
eine Protokollausgabe). Die peek() - Aktion wird wie jede andere intermediäre Operation ausge-
führt, wenn die terminale Operation der Pipeline ansteht. Im folgenden Beispiel erfolgt für einen
Strom mit den Zahlen von 1 bis 4 eine erste Kontrollausgabe unmittelbar hinter der Quelle. Nach-
dem die ungeraden Zahlen ausgefiltert worden sind, erfolgt eine erneute Kontrollausgabe:
Quellcode Ausgabe
import java.util.stream.IntStream; 1
2
class Peek { filtered: 2
public static void main(String[] args) { 3
int sum = IntStream.rangeClosed(1, 4) 4
.peek(System.out::println) filtered: 4
.filter(i-> i%2 == 0)
.peek(i-> System.out.println(" filtered: "+i)) Summe: 6
.sum();
System.out.println("\nSumme: "+sum);
}
}

Gemäß der ökonomischen Stromverarbeitungslogik nach dem „Lazy“-Prinzip (siehe Abschnitt


12.2.5.2) werden die ungeraden Zahlen nur einmal protokolliert (durch den ersten peek() - Aufruf),
die geraden Zahlen hingegen zweimal (durch beide peek() - Aufrufe).
Die Strommethode peek() ist wie forEach() (vgl. Abschnitt 12.2.5.3.3) ein sogenannter Nebenef-
fekt-Produzent, ohne (wie forEach()) die Pipeline zu terminieren.

12.2.5.4 Terminale Operationen


Terminale Operationen liefern ein Ergebnis, das kein Strom ist, oder führen eine Verarbeitung für
jedes einzelne Element aus. Auf jeden Fall sind die Stromobjekte in der Pipeline anschließend ver-
braucht und können keine weiteren Operationen mehr ausführen.
600 Kapitel 12 Funktionales Programmieren

12.2.5.4.1 Elementweise Verarbeitung (forEach)


Mit der in allen Stromschnittstellen vorhandenen Methode forEach() sorgt man für die elementwei-
se Verarbeitung eines Stroms. Im folgenden Beispiel werden die Elemente eines durch die
IntStream-Methode iterate() erstellten Stroms bestehend aus den ersten 8 Zweierpotenzen ausge-
geben (beginnend mit 1 = 20), wobei das forEach() - Parameterobjekt vom Typ IntConsumer
public void forEach(IntConsumer action)
auf einer Methodenreferenz basiert (siehe Abschnitt 12.1.3.1):
Quellcode Ausgabe
import java.util.stream.IntStream; 1
2
class ForEach { 4
public static void main(String[] args) { 8
IntStream intStr = IntStream.iterate(1, i -> 2*i).limit(8); 16
intStr.forEach(System.out::println); 32
} 64
} 128

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

12.2.5.4.3 Statistische Berechnungen ausführen (count, min, max, sum, average)


Für einige Reduktionsaufgaben (z. B. Ermittlung des maximalen Elements) sind spezielle Strom-
operationen verfügbar, die im Vergleich zu der parametrisierbaren Methode reduce() einfacher
handhabbar sind.
Von jedem beliebigen Strom kann man über die Methode count() die Anzahl seiner Elemente er-
fahren, z. B.:
Quellcode-Segment Ausgabe
IntStream isp = IntStream.of(1, 4, 10, 20); 4
System.out.println(isp.count());

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.

12.2.5.4.4 Stromelemente in einer Kollektion sammeln (collect)


Die in allen Stromschnittstellen vorhandene Methode collect() erstellt aus einem Strom eine Kollek-
tion. Von einer Stream<T> - Implementation wird eine collect() - Methode mit dem folgenden
Definitionskopf erwartet:
public <R> R collect(Supplier<R> supplier,
BiConsumer<R,? super T> accumulator,
BiConsumer<R,R> combiner)
Von den Parameterobjekten sind funktionale Schnittstellen zu erfüllen, um folgenden Aufgaben
gerecht zu werden:
• Erstellung eines Objekts vom Kollektionstyp R
Der erste Parameter von collect() muss das Interface Supplier<R> implementieren, das eine
parameterfreie Methode namens get() vorschreibt. Es wird erwartet, dass get() als Rückgabe
eine Kollektion vom Typ R liefert.
• Aufnahme eines Stromelements in die Kollektion
Der zweite Parameter von collect() muss das Interface BiConsumer<R, ? super T> imple-
mentieren, das eine Methode namens accept() mit dem Rückgabetyp void vorschreibt. Die-
se muss als ersten Parameter den Typ der Kollektion und als zweiten Parameter den Ele-
menttyp des Stroms oder eine Verallgemeinerung davon akzeptieren. Es wird erwartet, dass
mit accept() ein Stromelement in die Kollektion eingefügt werden kann.
Abschnitt 12.2 Ströme 605

• Aufnahme aller Elemente einer Kollektion in ein typgleiches Kollektionsobjekt


Diese Operation ist relevant, wenn bei paralleler Stromverarbeitung mehrere Zwischener-
gebnisse entstehen, die vereinigt werden müssen. Der dritte Parameter von collect() muss
das Interface BiConsumer<R, R> implementieren, und diesmal wird erwartet, dass per ac-
cept() alle Elemente einer Kollektion vom Typ R in ein Kollektionsobjekt desselben Typs
aufgenommen werden können.
Im folgenden Beispiel wird der aus einer String-Liste entstandene Strom per distinct() von Dublet-
ten befreit, per sorted() aufsteigend sortiert und schließlich per collect() in eine ArrayList<String>
-Objekt überführt:
Quellcode Ausgabe
import java.util.*; Anette
import java.util.stream.Stream; Anton
Ben
public class Collect { Berta
public static void main(String[] args) { Charly
List<String> alsa = Stream.of("Charly","Anton","Berta","Ben", Tom
"Anton","Anette","Charly", "Tom")
.distinct()
.sorted()
.collect(ArrayList<String>::new,
ArrayList<String>::add,
ArrayList<String>::addAll);
for(String s: alsa)
System.out.println(s);
}
}

Als collect() - Parameter fungieren Konstruktor- bzw. Methodenreferenzen (siehe Abschnitt


12.1.3). Der Konstruktor im ersten Parameter taugt zur Produktion eines ArrayList<String> - Kol-
lektionsobjekts. Als zweiter Parameter wird die Instanzmethode add() der Klasse Array-
List<String> übergeben, die einen Parameter vom Typ String erwartet und die Rolle der zu im-
plementierenden Schnittstellenmethode accept() spielen kann:
• Der erste accept() - Parameter (also das Kollektionsobjekt) wird zum Ansprechpartner für
den Aufruf der Instanzmethode add().
• Der zweite accept() - Parameter (also das Stromelement) wird an add() übergeben.
Als dritter collect() - Parameter wird die Instanzmethode addAll() der Klasse ArrayList<String>
übergeben, die einen Parameter vom Typ ArrayList<String> erwartet. Auch hier klappt offenbar
die Zuordnung der accept() - Parameter, die beide vom selben Kollektionstyp sein müssen.
Zu der etwas umständlichen collect() - Methode mit drei Parametern existiert eine Überladung mit
einem einzigen Parameter vom Interface-Typ Collector<? super T,A,R>, der die oben beschriebe-
nen Aufgaben (Kollektion erstellen, Element einfügen, andere Kollektion einfügen) zusammenfasst.
Besonders vorteilhaft ist, dass man ein Objekt des benötigten, nicht ganz trivialen Typs von einer
statischen Methode der Klasse Collectors aus dem Paket java.util.stream erstellen lassen kann.
Um ein passendes Kollektorobjekt für den collect() - Aufruf im Beispielprogramm zu erstellen,
verwendet man die Methode toList(). Schlussendlich erhält man bei deutlich reduziertem Aufwand
als Rückgabe von collect() einen Container vom Typ ArrayList<String>:
606 Kapitel 12 Funktionales Programmieren

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;

public class Joining {


public static void main(String[] args) {
List<String> ls = List.of("Charly", "Anton", "Berta");
String s = ls.stream()
.collect(Collectors.joining(", "));
System.out.println(s);
}
}

12.2.5.4.5 Stromelemente in einem Array sammeln (toArray)


Im folgenden Beispiel wird ein IntStream-Objekt von Dubletten befreit und anschließend mit der
IntStream-Methode toArray() in einen int-Array gewandelt:
Quellcode Ausgabe
import java.util.stream.IntStream; 1
2
public class ToArray { 3
public static void main(String[] args) { 4
int[] istar = IntStream.of(1, 1, 2, 3, 3, 4, 5, 5) 5
.distinct()
.toArray();
for(int i : istar)
System.out.println(i);
}
}

12.2.5.4.6 Strombezogene Bedingungen prüfen (anyMatch, allMatch, noneMatch)


Mit den Methoden anyMatch(), allMatch() und noneMatch(), die allesamt einen boolean-
Rückgabewert liefern, lässt sich für einen Strom feststellen, ob eine Bedingung bei mindestens ei-
nem Element, bei allen Elementen oder bei keinem Element erfüllt ist. Alle Methoden benötigen als
Parameter ein Objekt, das eine Methode namens test() mit einem boolean-Rückgabewert zur Beur-
teilung eines einzelnen Stromelements beherrscht. Bei den Stream<T> - Methoden zur
strombezogenen Bedingungsprüfung muss besagtes Objekt zu einer Klasse gehören, welche das
Interface Predicate<? super T> erfüllt, sodass sich z. B. für die Methode anyMatch() der folgende
Definitionskopf ergibt:
public boolean anyMatch(Predicate<? super T> predicate)
Im folgenden Programm wird für ein Objekt vom Typ Stream<String> mit der Methode
anyMatch() geprüft, ob mindestens ein Element mit genau 5 Zeichen vorhanden ist:
608 Kapitel 12 Funktionales Programmieren

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.

12.2.5.4.7 Extrahieren eines Elements (findFirst, findAny)


Mit den Methoden findFirst() bzw. findAny() erhält man in Optional-Verpackung das erste bzw.
irgendein Element des angesprochenen Stroms oder ein leeres Optional-Objekt (siehe Abschnitt
11.4.2), falls der Strom leer ist. In der Regel wird man den Strom vorher filtern, um an ein interes-
santes Objekt heranzukommen.
Weil findAny() mit dem Ziel maximaler Performanz bei paralleler Stromverarbeitung explizit die
Freiheit hat, irgendein Element zu liefern, ist bei mehreren Aufrufen mit unterschiedlichen Rückga-
ben zu rechnen. Durch Verwendung der Methode findFirst() lässt sich dieser Indeterminismus be-
seitigen.
Das folgende Programm wendet die beiden Methoden findAny() und findFirst() auf denselben
gefilterten Strom an und vermeidet dabei die Wiederholung der Stromdefinition, um nicht gegen
das DRY-Prinzip zu verstoßen (Don’t Repeat Yourself). Dazu wird die Methode crFStream()
eingesetzt, die ein frisches gefiltertes Stromobjekt basierend auf einer festen Namensliste liefert:
Quellcode Ausgabe
import java.util.*; Ein Treffer: Optional[Emma]
import java.util.stream.Stream; Erster Treffer: Optional[Emma]

class FindAnyFirst {
static Stream<String> crFStream() {
return Stream.of("Rudolf", "Emma", "Otto",
"Agnes", "Kurt")
.filter(s -> s.length() == 4);
}

public static void main(String[] args) {


Optional<String> einTreffer = crFStream().findAny();
System.out.println("Ein Treffer: " + einTreffer);
Optional<String> ersterTreffer = crFStream().findFirst();
System.out.println("Erster Treffer: " + ersterTreffer);
}
}

12.3 Empfehlungen für erfolgreiches funktionales Programmieren


Subramaniam (2014, S. 12ff) empfiehlt den Java-Entwicklern einige Änderungen ihres Program-
mierstils, um einen hohen Nutzen aus der funktionalen Option in Java zu ziehen.

12.3.1.1 Deklarieren statt Kommandieren


Was sich hinter dieser Empfehlung verbirgt, soll durch ein Beispiel geklärt werden. Wir gehen aus
von einer Liste mit Vornamen:
Abschnitt 12.3 Empfehlungen für erfolgreiches funktionales Programmieren 609

List<String> als = List.of("Viktor", "Otto", "Emma", "Kurt");


Um die mittlere Länge der Vornamen mit mindestens 5 Zeichen zu ermitteln, ist im traditionellen
Stil durch eine längliche Serie von Anweisungen zu kommandieren, wie vorzugehen ist:
double summe = 0.0;
int n = 0;
for (String s : als)
if (s.length() >= 5) {
summe += s.length();
n++;
}
System.out.println("Mittlere Länge der Namen mit >= 5 Zeichen: " +
(n > 0 ? summe/n : "nicht vorhanden"));
Im funktionalen Stil deklariert man, was zu tun ist:
• Aus dem List<String> - Objekt ein Stream<String> - Objekt erstellen
• Elemente mit weniger als 5 Zeichen ausschließen
• Das Stream<String> - Objekt in ein IntStream-Objekt wandeln, der zu jeder Zeichenfolge
die Länge enthält
• Den Mittelwert der Elemente im IntStream-Objekt bestimmen
In der folgenden Lösung wird eine Verarbeitungs-Pipeline mit drei Operationen nach dem Filter-
Map-Reduce - Schema deklariert:
OptionalDouble mlge5 = als.stream()
.filter(s -> s.length() >= 5)
.mapToInt(s -> s.length())
.average();
System.out.println("Mittlere Länge der Namen mit >= 5 Zeichen: " +
(mlge5.isPresent() ? mlge5.getAsDouble() : "nicht vorhanden"));
Die Details der Ausführung bleiben den beteiligten Bibliotheksklassen überlassen:
• Es werden keine Hilfsvariablen (wie summe und n in der traditionellen Lösung) deklariert,
initialisiert und aktualisiert.
• Die Iterationen laufen intern (gekapselt in Bibliotheksklassen) ab.
Man erspart sich viel Aufwand und viele Fehlermöglichkeiten.
Im Beispiel werden die beiden zu Beginn von Kapitel 12 erwähnten Kerntechniken der funktionalen
Programmierung mit Java eingesetzt:
• Aus der String-Liste macht die Methode stream() aus dem parametrisierten Interface
Collection<String> eine Sequenz von Elementen mit der Potenz zur bequemen Massenbe-
arbeitung, also einen Strom. Es folgen zwei intermediäre Stromoperationen, die zu neuen
Strömen führen (filter(), mapTpInt()) sowie eine terminale Stromoperation (average()).
• Die beiden ersten Stromoperationen benötigen eine Funktion, die auf jedes Element des
Stroms angewendet werden soll. Während man bis Java 7 in einer solchen Situation meist
ein Objekt einer ad hoc definierten anonymen Klasse als Parameter übergeben hat, ist es seit
Java 8 möglich, die benötigte Funktionalität syntaktisch einfacher und eleganter per Lamb-
da-Ausdruck zu definieren.

12.3.1.2 Veränderliche Variablen vermeiden


Code mit vielen veränderlichen Variablen ist fehleranfällig, relativ schwer zu verstehen und
schlecht zu parallelisieren, d .h. auf mehrere Prozessorkerne zu verteilen. Im eben vorgestellten
Beispiel (siehe Abschnitt 12.3.1.1) enthält die traditionelle Lösung sehr viele Wertzuweisungen, die
610 Kapitel 12 Funktionales Programmieren

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.

12.3.1.3 Seiteneffekte vermeiden


Ein grundlegendes Designmerkmal funktionaler Programmiersprachen, das in Java seit der Version
8 ermöglicht, aber nicht erzwungen wird, ist der Verzicht auf Seiteneffekte in Methoden. Wenn sich
Methoden strikt darauf beschränken, aus den Parametern ein Ergebnis zu produzieren und als
Rückgabe abzuliefern, dann steigt die Chance auf eine quasi-automatische Parallelisierung.

12.3.1.4 Ausdrücke bevorzugen gegenüber Anweisungen


Subramaniam (2014, S. 13f) empfiehlt, Ausdrücke gegenüber Anweisungen zu bevorzugen. Wäh-
rend Anweisungen zu vielen Wertveränderungen führen, lassen sich Ausdrücke gut zu Verarbei-
tungsketten zusammensetzen. Die traditionelle Lösung im Abschnitt 12.3.1.1 arbeitet mit 6 Anwei-
sungen:
double summe = 0.0; // 1
int n = 0; // 2
for (String s : als) // 3
if (s.length() >= 5) { // 4
summe += s.length(); // 5
n++; // 6
}
Demgegenüber beschränkt sich die funktionale Lösung auf eine einzige Anweisung, wobei einer
Ergebnisvariablen ein Ausdruck zugewiesen wird, der aus einer Sequenz von Methodenaufrufen
besteht:
OptionalDouble mlge5 =
als.stream().filter(s -> s.length() >= 5).mapToInt(s -> s.length()).average();

12.3.1.5 Verwendung von Funktionen höherer Ordnung


Beim funktionalen Programmierstil ist oft erforderlich, Funktionen als Parameter an andere Funkti-
onen zu übergeben. In unserem Beispiel aus dem Abschnitt 12.3.1.1 wird an die Stream<String> -
Methode filter() als Aktualparameter ein Lambda-Ausdruck übergeben:
s -> s.length() >= 5
Dabei handelt sich um eine Funktion, die auf jedes Element des Stroms angewendet werden soll.
Um die Übergabe einer Funktion an eine andere Funktion darzustellen, musste das Typsystem von
Java nicht geändert werden. Hinter den Kulissen entsteht aus dem Lambda-Ausdruck eine anonyme
Klasse, und ein Objekt dieser Klasse wird an filter() als Aktualparameter übergeben. Als Datentyp
verlangt filter()
Stream<T> filter(Predicate<? super T> predicate)
bei seinem Parameter ein Objekt einer Klasse welche das Interface Predicate<? super T> erfüllt
und daher die folgende Methode implementiert:
public boolean test(? super T t)
Der obige Lambda-Ausdruck passt zu dieser Methode:
• Aus der Parameterliste vor dem Pfeil ergibt sich, dass ein Parameter vorhanden ist.
• Als Datentyp wird für diesen Parameter der Elementtyp des Stroms (String) angenommen.
Abschnitt 12.4 Übungsaufgaben zum Kapitel 12 611

• 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;

public class Func2ndOrder {

static Predicate<String> lenTest(int k) {


return s -> s.length() >= k;
}

public static void main(String[] args) {


List<String> als = List.of("Viktor", "Otto", "Emma", "Kurt");
int k = 4;

OptionalDouble mlgtk = als.stream()


.filter(lenTest(k))
.mapToInt(s -> s.length())
.average();
System.out.println("Mittlere Länge der Namen mit >= " + k + " Zeichen: " +
(mlgtk.isPresent() ? mlgtk.getAsDouble() : "nicht vorhanden"));
}
}
arbeitet lenTest() als sogenannte Funktion höherer Ordnung, weil Funktionsobjekte erstellt und
per Rückgabe geliefert werden. Diese Rückgaben eignen sich als Parameterobjekte vom Typ Predi-
cate<String> für die Stromoperation filter().

12.4 Übungsaufgaben zum Kapitel 12


1) Erstellen Sie eine Anweisung zur Berechnung der Summe aus den ersten 100 quadrierten natürli-
chen Zahlen. Vermutlich werden Sie eine elementweise abbildende Stromoperation verwenden (vgl.
Abschnitt 12.2.5.3.3). Definieren Sie den Mapper übungshalber über eine anonyme Klasse und über
einen Lambda-Ausdruck.

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

einer Object-Referenzvariablen kein Lambda-Ausdruck zugewiesen werden, wie z. B. die folgende


Fehlermeldung von IntelliJ IDEA zeigt:

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

13.1.1 Vergleich von Konsolen- und GUI-Programmen


Im Vergleich zu Konsolenprogrammen geht es bei GUI-Anwendungen nicht nur anschaulicher und
intuitiver zu, sondern vor allem auch ereignisreicher und mit mehr Mitspracherechten für den An-
wender. Ein Konsolenprogramm entscheidet selbst darüber, welche Anweisung als nächstes ausge-
führt wird, und wann der Benutzer eine Eingabe machen darf. Um seine Aufgaben zu erledigen,
verwendet ein Konsolenprogramm diverse Dienste des Laufzeitsystems, z. B. bei der Aus- oder
Eingabe von Zeichen.
Für den Ablauf eines Programms mit grafischer Bedienoberfläche ist hingegen ein ereignisorien-
tiertes und benutzergesteuertes Paradigma wesentlich, wobei das Laufzeitsystem als Vermittler

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)

wird der gesamte Ablauf vom Programm diktiert:


• Es fragt nach dem Zähler und dem Nenner des ersten Bruchs.
• Es fragt nach dem Zähler und dem Nenner des zweiten Bruchs.
• Es schreibt das Ergebnis auf die Konsole.
Wenn der Benutzer z. B. nach der Eingabe des Nenners zum ersten Bruch den Zähler dieses Bruchs
noch einmal ändern möchte, dann muss er das Programm beenden und neu starten.
Im Unterschied zu diesem programmgesteuerten Ablauf wird bei der GUI-Variante
Abschnitt 13.1 Einordnung 615

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.

13.1.2 Desktop-GUI-Lösungen in Java


Java hat von Beginn an die plattformunabhängige GUI-Programmierung ermöglicht, und die dazu
verwendete Technologie wurde sukzessive weiterentwickelt. Die ursprüngliche, als Abstract Win-
dowing Toolkit (AWT) bezeichnete GUI-Bibliothek wurde schon in Java 1.2 durch das Swing
Toolkit erweitert und teilweise ersetzt. Nachdem Swing über viele Jahre der unangefochtene Stan-
dard für GUI-Desktop-Anwendungen in Java war, wurde diese Rolle 2014 mit der Java-Version 8
von JavaFX übernommen. Zu diesem Zeitpunkt hatte JavaFX schon einige Entwicklungsschritte
hinter sich:
• JavaFX 1.x
Von der Firma Sun wurde 2008 mit eher mäßigem Erfolg die Programmiersprache JavaFX
Script auf den Markt gebracht, um die Entwicklung von grafischen Bedienoberflächen zu
vereinfachen.
• JavaFX 2.x
Im Jahr 2011 hat die Firma Oracle (nach Übernahme der Firma Sun) unter der Bezeichnung
JavaFX 2.0 ein deutlich attraktiveres Angebot vorgestellt:
o Man hat die separate Programmiersprache JavaFX Script aufgegeben und die attrak-
tiven Teile von JavaFX als Klassenbibliotheken in Java integriert (z. B. eine neue
Steuerelementfamilie, Grafikausgabe mit plattformspezifischer Hardware-
Beschleunigung).
o Es wurde eine XML-basierte GUI-Deklaration ermöglicht.
• JavaFX 8
Als JavaFX im Jahr 2014 zum Standard für grafische Desktop-Bedienoberflächen in Java 8
befördert wurde, machte die JavaFX-Version einen Sprung von 2.2 auf 8. Seitdem stimmen
die Versionsstände von JavaFX und Java SE überein. Somit arbeiten wir aktuell (im Januar
2022) mit JavaFX 17.
616 Kapitel 13 GUI-Programmierung mit JavaFX

• JavaFX (alias OpenJFX) ab Version 11


Beginnend mit Java 11 wurde JavaFX von der Firma Oracle aus dem JDK entfernt, wovon
auch die OpenJDK-Distributionen betroffen sind. JavaFX bleibt unter dem Namen OpenJFX
aber als Open Source - Software frei verfügbar (wie das OpenJDK unter der GPL) und wird
aktiv weiterentwickelt,1 wobei die Firma Gluon die Federführung übernommen hat.2 Man
kann davon ausgehen, dass für JavaFX wie für das JDK in absehbarer Zukunft halbjährliche
Updates erscheinen werden. Für Entwickler hat die neue Situation folgende Konsequenzen:
o Weil JavaFX in keiner OpenJDK-Distribution mit einer Version > 8 enthalten ist,
muss es explizit in eigene Anwendungen integriert werden.
o Ein Long-Term-Support ist nur kommerziell von der Firma Gluon zu haben. Wer Ja-
vaFX kostenfrei und sicher nutzen will, muss also halbjährlich auf die aktuelle Ver-
sion umsteigen. Beim OpenJDK ist die Lage durch etliche kostenlos verfügbare Dis-
tributionen mit langfristig zugesagter Update-Versorgung wesentlich günstiger (sie-
he Abschnitt 2.1.2).
Neben Steuerelementen kann ein JavaFX-Fenster auch 2- oder 3-dimensionale Grafikelemente zei-
gen, was in diesem Kapitel ebenso wenig thematisiert wird wie die JavaFX-Kompetenzen zur Dar-
stellung von Effekten (z. B. Schatten, Weichzeichnen) und Animationen (z. B. wandernde, größen-
variable Elemente). Leider fehlen in diesem unfertigen, aber hoffentlich trotzdem nützlichen Kapi-
tel noch weitere JavaFX-Techniken, z. B.:
• Erscheinungsbild einer Anwendung mit CSS (Cascading Style Sheets) individualisieren3
• Multimedia-Inhalte darstellen (Audio, Video)
Die meisten GUI-Programme kommen allerdings ohne die in diesem Kapitel noch fehlenden Ja-
vaFX-Techniken aus.
Den Multithreading-Bezügen von JavaFX gehen wir im aktuellen Kapitel aus dem Weg, holen de-
ren Behandlung aber später nach (siehe Abschnitt 15.8).
Wie JavaFX (alias OpenJFX) sowie das als grafischer Fensterdesigner sehr empfehlenswerte Pro-
gramm Scene Builder zu beziehen und zu installieren sind, wurde im Abschnitt 2.5 beschrieben.
Wie sich gleich im Abschnitt 13.2 zeigen wird, sind ein OpenJDK inklusive JavaFX oder eine er-
gänzende lokale JavaFX-Installation aber keine Voraussetzung für die Verwendung der Bibliothek.
An Literatur zu JavaFX herrscht kein Mangel. Eine kompakte Beschreibung bietet z. B. Inden
(2015), eine ausführliche ist z. B. bei Epple (2015) oder Sharan (2015) zu finden.
Während bei neuen Projekten mit grafischer Bedienoberfläche auf JavaFX gesetzt werden sollte,
müssen vorhandene Swing-Programme sicher noch für geraume Zeit gepflegt werden. Wer eine
Einführung in die GUI-Programmierung mit Swing sucht, wird z. B. bei Baltes-Götz & Götz (2016)
fündig.
Neben den im Java-API vorhandenen bzw. (bei OpenJFX) mit dem Java-API assoziierten GUI-
Toolkits sind noch andere Lösungen verfügbar, wobei besonders das im Eclipse-Projekt entwickelte
Standard Widget Toolkit (SWT) zu erwähnen ist.

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

13.2 Einstieg in JavaFX


Während mit der älteren GUI-Technik Swing die Bedienoberfläche einer Anwendung grundsätzlich
programmgesteuert erstellt wird, bietet JavaFX neben dieser traditionellen Vorgehensweise auch
die Möglichkeit zur deklarativen GUI-Gestaltung über einen XML-Dialekt namens FXML
(JavaFX Markup Language). Die nicht nur in JavaFX, sondern z. B. auch im Windows Presentati-
on Framework (WPF) der .NET - Plattform (siehe z. B. Baltes-Götz 2021) sowie im Android-SDK
(siehe z. B. Baltes-Götz 2018) bevorzugte deklarative Technik bietet u. a. die folgenden Vorteile:
• Bessere Trennung von Design und Funktion
Diese Trennung erleichtert die Pflege der beiden Bestandteile und ermöglicht eine arbeitstei-
lige Kooperation von Programmierern und GUI-Designern.
• Bessere Übersicht bei komplexen Bedienoberflächen
Die deklarative GUI-Gestaltung auf FXML-Basis wird durch den grafischen Fensterdesigner Scene
Builder wesentlich erleichtert. Wir haben beim ersten JavaFX-Einsatz im Abschnitt 4.9 die deklara-
tive GUI-Gestaltung mit Hilfe des Scene Builders verwendet, und diese Arbeitsweise ist auch für
den Alltag der GUI-Software-Entwicklung in Java zu empfehlen. Im aktuellen Kapitel kommt al-
lerdings vielfach die programmatische Vorgehensweise zum Einsatz, weil dabei manche Details der
JavaFX-Anwendungsstruktur besser erläutert werden können.

13.2.1 Beispiel Anwesenheitsliste


Als erstes Beispiel erstellen wir ein Programm, das z. B. zur Anwesenheitsüberwachung durch ei-
nen Blockwart mit freiem Blick auf den einzigen Hauseingang dienen kann:

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

und entscheiden uns im folgenden Assistentendialog

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;

public class HelloApplication extends Application {


@Override
public void start(Stage stage) throws IOException {
FXMLLoader fxmlLoader = new
FXMLLoader(HelloApplication.class.getResource("hello-view.fxml"));
Scene scene = new Scene(fxmlLoader.load(), 320, 240);
stage.setTitle("Hello!");
stage.setScene(scene);
stage.show();
}

public static void main(String[] args) {


launch();
}
}

Wir ändern den Namen der Startklasse per Refaktorierung in Attendance.


Wird für ein Programm nur eine Ausführungskonfiguration benötigt, 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 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.

13.2.2 Lebenszyklus einer JavaFX-Anwendung


Die Haupt- bzw. Anwendungsklasse einer JavaFX-Anwendung muss von der Klasse Application
im Paket javafx.application abgeleitet werden und den Zugriffsmodifikator public erhalten. z. B.:
public class Attendance extends Application {
. . .
}
Damit ein Objekt der Anwendungsklasse erzeugt werden kann, muss ein parameterfreier Konstruk-
tor öffentlich verfügbar sein. Im Beispiel ist diese Bedingung erfüllt, weil der Standardkonstruktor
vorhanden ist, der die Zugriffsstufe public von der Klasse übernimmt.
In der Anlaufphase einer JavaFX-Anwendung spielt die in der Basisklasse Application abstrakt
definierte, also auf jeden Fall zu implementierende Methode start() eine wichtige Rolle. Sie wird
(indirekt) von der Application-Methode launch() aufgerufen, die bis zum Ende der Anwendung
läuft. Für den Aufruf von launch() sorgt die main() - Methode der Anwendungsklasse, und mehr
sollte dort bei einer JavaFX-Anwendung nicht passieren:
public static void main(String[] args) {
launch(args);
}
Die Methode main() der Anwendungsklasse darf sogar fehlen. Wie man per Debugger (vgl. Ab-
schnitt 4.3.3)

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

Insgesamt resultiert der folgende Lebenszyklus einer JavaFX-Anwendung:1


• Die JavaFX-Laufzeitumgebung wird gestartet.
• Die Anwendungsklasse wird geladen und instanziert.
• Ggf. wird die Application-Methode init() aufgerufen.
• Die Application-Methode start() wird aufgerufen.
• Die Anwendung endet auf jeden Fall beim Aufruf der statischen Platform-Methode exit()
und per Voreinstellung auch beim Schließen des letzten Fensters.
• Ggf. wird die Application-Methode stop() aufgerufen. Die Platform-Methode exit() muss
unbedingt gegenüber der System-Methode exit() bevorzugt werden, weil anderenfalls der
stop() - Aufruf unterbleibt.

13.2.3 Bühne, Szene und Szenengraph


In einer JavaFX-Anwendung fungiert ein Fenster des Wirtsbetriebssystems als Bühne, wobei auch
mehrere Fenster bzw. Bühnen simultan bespielt werden können. Zur primären Bühne erhält die
Application-Methode start() beim Aufruf per Parameter eine Referenz vom Typ Stage (aus dem
Paket javafx.stage).
Um Leben auf eine Bühne zu bringen, muss ein Objekt der Klasse Scene (aus dem Paket
javafx.scene) eingesetzt werden. Ein Stage-Objekt enthält (zu einem Zeitpunkt) genau ein Scene-
Objekt, das seinerseits als Container für beliebig viele visuelle Komponenten (Bedienelemente
und/oder Grafikelemente) dient.
Es gehört zu den Aufgaben der Methode start(),
• ein Scene-Objekt zu erstellen, zu bestücken und dem Stage-Objekt zuzuweisen,
• das Stage-Objekt (also das Anwendungsfenster) sichtbar zu machen.
Die Komponenten in der Szene stammen von der Klasse Node (dt.: Knoten) im Paket javafc.scene
ab und sind hierarchisch in einem Szenengraphen (engl. scene graph) angeordnet mit einem Wur-
zelknoten an der Spitze, der in den Scene-Konstruktoren vereinbart wird. Alle Knoten im Szenen-
graphen, die Kinder (eingeschachtelte Knoten) haben können, stammen von der Klasse Parent im
Paket javafx.scene ab:

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

AnchorPane BorderPane FlowPane GridPane HBox StackPane VBox

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

Blattknoten Blattknoten Elternknoten

Elternknoten Elternknoten Blattknoten

Die folgende Abbildung sind die Abstammungsverhältnisse der Klassen Stage und Scene:
java.lang.Object

javafx.scene.Scene javafx.stage.Window

javafx.stage.Stage

13.3 Anwendung mit All-In-One - Architektur


Wir werden das im Abschnitt 13.2 vorgestellte Beispielprogramm zur Anwesenheitsüberwachung
gleich zweimal realisieren:
• Im aktuellen Abschnitt 13.3 wird das Programm durch eine einzige Klasse realisiert, die sich
auch um die Bedienoberfläche kümmert und diese auf traditionelle Weise durch Anweisun-
gen erstellt. Diese Vorgehensweise macht einige Merkmale der JavaFX-Anwendungs-
architektur deutlich und ist daher in der Lernphase angemessen.
• Im Abschnitt 13.4 lernen wir die für größere Projekte empfehlenswerte Vorgehensweise
kennen, die sich moderner Techniken bedient (z. B. Layout-Deklaration per FXML) und ei-
ne Aufgabenverteilung auf mehrere Klassen vornimmt.
626 Kapitel 13 GUI-Programmierung mit JavaFX

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

Im Scene-Konstruktor stellen wir eine passende initiale Fenstergröße ein:


stage.setScene(new Scene(root, 450, 200));
Wenn der Benutzer die Fenstergröße ändert, wird der verfügbare Platz neu auf die Bedienelemente
aufgeteilt. Bei manchen Fenstern ist es sinnvoll, mit der Stage-Methode setResizable() eine Ände-
rung der Fenstergröße zu verhindern:
stage.setResizable(false);
Die Bühne erhält einen Titel und den Auftrag, mit der Vorstellung zu beginnen:
primaryStage.setTitle("Anwesenheitskontrolle");
primaryStage.show();
Unser Programm ist einsatzbereit (hier noch mit Gitterlinien zur Layout-Begutachtung):

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;

public class Attendance extends Application {


@Override
public void start(Stage stage) {
GridPane root = new GridPane();
double dist = 10.0;
root.setPadding(new Insets(dist, dist, dist, dist));
root.setHgap(dist); root.setVgap(dist);

Label lblName = new Label("Name:");


Label lblPresent = new Label("Anwesend:");
TextField tfName = new TextField();
Button btnAdd = new Button("Angekommen");
Button btnRemove = new Button("Gegangen");
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);

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);

stage.setScene(new Scene(root, 450, 200));


stage.setTitle("Anwesenheitskontrolle");
stage.setMinWidth(400);
stage.setMinHeight(150);
stage.show();
}

public static void main(String[] args) {


launch();
}
}

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

13.4 Anwendung mit Model-View-Controller - Architektur (MVC)

13.4.1 Das Model-View-Controller - Konzept


Ein objektorientiertes Programm muss ...
• die den Zustand des modellierten Systems repräsentierenden Daten unter Beachtung von
Regeln der Geschäftslogik durch Anwendung von mehr oder weniger komplexen Algorith-
men verwalten,
• mit den Benutzern interagieren, die meist mit Hilfe einer grafischen Bedienoberfläche Daten
einsehen und verändern wollen.
Es ist allgemeiner Konsens, dass in einer objektorientierten Software die Verwaltung und Trans-
formation der Daten nach den Regeln der Geschäftslogik getrennt werden sollte von der Präsentati-
on der Daten und der Benutzerinteraktion (siehe z. B. Sharan 2015, S. 419ff). Seit ca. 30 Jahren
werden Vorschläge zur Architektur von objektorientierter Software und zur Aufgabenverteilung
entwickelt, wobei das zusammen mit der Programmiersprache Smalltalk eingeführte Model-View-
Controller (MVC) - Konzept sehr oft, aber leider mit ziemlich variabler Bedeutung genannt wird
(Lahres & Rayman 2009, Abschnitt 8.2).
Wir übernehmen von Hommel (2014) die folgenden Erläuterungen zu den Begriffen Model, View
und Controller:
• Model
Unter dem Model einer Anwendung soll die Sammlung der Klassen bzw. Objekte verstan-
den werden, die die Daten verwalten und die anwendungsspezifischen Algorithmen realisie-
ren. Sie veröffentlichen Daten (z. B. durch Mitteilungen an registrierte Beobachter) und er-
lauben eine Datenmodifikation durch Controller-Objekte (siehe unten).
• View
Die View-Komponente einer JavaFX-Anwendung besteht aus den (meist per FXML dekla-
rierten) Fenstern (Szenen). Als Knoten in den Szenegraphen der Fenster sind Bedienelemen-
te vorhanden (z. B. aus den Klassen ListView<E>, TextField, Button), die eigenständig
mit dem Benutzer interagieren, registrierte Controller-Objekte über eingetretene Ereignisse
informieren (z. B. Mausklick auf einen Befehlsschalter) und von Model-Objekten übermit-
telte Daten darstellen.
• Controller
Jedem Fenster (jeder Szene) wird eine Controller-Klasse mit Ereignisbehandlungsmethoden
zugeordnet, welche sich von View-Objekten über Ereignisse informieren lassen, diese be-
werten und ggf. Veränderungen bei Model-Objekten vornehmen. Neben der indirekten Be-
einflussung von View-Objekten über die Model-Daten kommen auch direkte Modifikatio-
nen in Frage (z. B. das (De)aktivieren von View-Objekten).
In der folgenden Abbildung sind die Anwendungsbestandteile und ihre Kommunikationsbeziehun-
gen dargestellt:
Abschnitt 13.4 Anwendung mit Model-View-Controller - Architektur (MVC) 631

Information
über Ereignisse
Controller View Interaktion mit
Anweisungen dem Benutzer
zur Darstellung

Anweisungen zur Veröffentlichung von Daten,


Modelländerung Information über Änderungen

Model

13.4.2 Projekt anlegen


Wir erstellen nun das im Abschnitt 13.2 beschriebene Beispielprogramm zur Anwesenheitskontrolle
erneut ...
• unter Verwendung der View-Deklaration per FXML
• und mit Beachtung der MVC-Anwendungsarchitektur.
Die ersten Schritte beginnend mit dem Menübefehl
File > New > Project
laufen analog zu Abschnitt 13.2.1 ab:
• Das Projekt erhält den Namen AttendanceMVC.
• Damit das Paket zur Anwendung 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.
Damit resultiert der folgende Startdialog des Assistenten:

Wir benötigen keine Optimierung der JavaFX-Optik:


632 Kapitel 13 GUI-Programmierung mit JavaFX

Per Refaktorierung erhält ...


• die vom Assistenten angelegte Klasse HelloApplication den Namen Attendance,
• die vom Assistenten angelegte Klasse HelloController den Namen Controller,
• die vom Assistenten angelegte GUI-Deklarationsdatei hello-view.fxml den Namen
attendance.fxml.

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 class Model {


private ObservableList<String> persons;
private SortedList<String> perSorted;

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());
}

public ObservableList<String> getSortedList() {


return FXCollections.unmodifiableObservableList(perSorted);
}

public void add(String s) {


if (!persons.contains(s))
persons.add(s);
}

public void remove(String s) {


persons.remove(s);
}
}

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);
}

13.4.4 GUI-Gestaltung per Scene Builder


Zum Öffnen der FXML-Datei attendance.fxml im Scene Builder bestehen zwei Möglichkeiten:
• Öffnen im IntelliJ-Rahmen
Dazu wechselt man im Editor bei geöffneter Datei attendance.fxml zur Registerkarte Sce-
ne Builder. Zumindest unter Windows gibt es traditionell mit dieser Funktion Schwierig-
keiten.
• Öffnen als selbständiges Programm
Diese Variante führt zu einer reichhaltigeren Scene Builder - Bedienoberfläche (z. B. mit
Menü). Wir bevorzugen diese Option und wählen daher aus dem Kontextmenü der Editor-
Registerkarte zur FXML-Datei das Item Open in SceneBuilder:
634 Kapitel 13 GUI-Programmierung mit JavaFX

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

<?xml version="1.0" encoding="UTF-8"?>

<?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?>

<GridPane hgap="10.0" maxHeight="-Infinity" maxWidth="-Infinity" minHeight="-Infinity"


minWidth="-Infinity" prefHeight="200.0" prefWidth="450.0" vgap="10.0"
xmlns:fx="http://javafx.com/fxml/1" xmlns="http://javafx.com/javafx/17"
fx:controller="de.uni_trier.zimk.attendance.Controller">
<columnConstraints>
<ColumnConstraints hgrow="SOMETIMES" minWidth="10.0" prefWidth="100.0" />
<ColumnConstraints hgrow="SOMETIMES" minWidth="10.0" prefWidth="100.0" />
<ColumnConstraints hgrow="SOMETIMES" minWidth="10.0" prefWidth="100.0" />
</columnConstraints>
<rowConstraints>
<RowConstraints minHeight="10.0" prefHeight="30.0" vgrow="SOMETIMES" />
<RowConstraints minHeight="10.0" prefHeight="30.0" vgrow="SOMETIMES" />
</rowConstraints>
<children>
<Label text="Name:" />
<Label text="Anwesend:" GridPane.rowIndex="1" />
<Button fx:id="btnAdd" mnemonicParsing="false" text="Angekommen"
GridPane.columnIndex="2" />
<Button fx:id="btnRemove" mnemonicParsing="false" text="Gegangen"
GridPane.columnIndex="2" GridPane.rowIndex="1" />
<TextField fx:id="tfName" GridPane.columnIndex="1" GridPane.hgrow="ALWAYS" />
<ListView fx:id="lvPersons" prefHeight="200.0" prefWidth="200.0"
GridPane.columnIndex="1" GridPane.rowIndex="1" GridPane.vgrow="ALWAYS" />
</children>
<padding>
<Insets bottom="10.0" left="10.0" right="10.0" top="10.0" />
</padding>
</GridPane>

Die Datei startet mit sogenannten XML-Verarbeitungsinstruktionen (engl.: processing Instructions),


die Anweisungen an den XML-Parser enthalten:
<? ... ?>
Auf eine Zeile mit der XML-Versions- und Codierungsangabe folgen Importdeklarationen für Java-
Klassen analog zu Abschnitt 3.1.7, wobei auch der Joker-Stern (*) erlaubt ist.
Das FXML-Wurzelelement namens GridPane enthält die im Scene Builder vorgenommenen Ein-
stellungen:
<GridPane hgap="10.0" maxHeight="-Infinity" maxWidth="-Infinity" minHeight="-Infinity"
minWidth="-Infinity" prefHeight="200.0" prefWidth="450.0" vgap="10.0"
xmlns:fx="http://javafx.com/fxml/1"
xmlns="http://javafx.com/javafx/17"
fx:controller="de.uni_trier.zimk.attendance.Controller">
. . .
</GridPane>

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>

Im children-Element werden die im GridPane-Container enthaltenen Knoten aufgelistet:


<children>
. . . .
</children>
Die Elemente zu den Knotenobjekten tragen den Namen der realisierenden Java-Klasse. Im späte-
ren Programmablauf werden die Bedienelemente über den parameterfreien Konstruktor ihrer Klasse
erzeugt.
Wie im folgenden Beispiel treten Attributnamen mit (z. B. GridPane.rowIndex) und ohne (z. B.
text) Klassenpräfix auf:
<Button fx:id="btnRemove" mnemonicParsing="false" text="Gegangen"
GridPane.columnIndex="2" GridPane.rowIndex="1" />
Bei Attributen ohne Klassenpräfix wird eine Instanzmethode der Steuerelementklasse zum Setzen
einer Eigenschaftsausprägung aufgerufen (ein Property-Setter, vgl. Abschnitt 13.5). Bei Attributen
mit Klassenpräfix wird eine statische Methode der Layoutmanager-Klasse aufgerufen (Horstmann
2014, S. 87), z. B.
GridPane.setColumnIndex(btnRemove, 2);
Weitere Hinweise zum FXML-Format finden sich z. B. bei Weaver et al (2014). Wie eine FXML-
Datei beim Programmstart geladen wird, erfahren Sie im Abschnitt 13.4.7.

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;

public class Controller {


@FXML
private Label welcomeText;

@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

per Enter den vorgeschlagenen Datentyp.


• Verfahren Sie analog mit den GUI-Komponenten bzw. Feldern btnRemove, tfName und
lvPersons.
Dass IntelliJ öffentliche Instanzvariablen verwendet, verstößt gegen das Prinzip der Datenkapse-
lung:
public class Controller {
public Button btnAdd;
public Button btnRemove;
public TextField tfName;
public ListView lvPersons;
@FXML
private Label welcomeText;

@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

Im FXML-Code würde die Vereinbarung der Ereignisbehandlungsmethoden so umgesetzt werden:


<Button fx:id="btnAdd" mnemonicParsing="false" onAction="#btnAddActionHandler"
text="Angekommen" GridPane.columnIndex="2" />
<Button fx:id="btnRemove" mnemonicParsing="false" onAction="#btnRemoveActionHandler
text="Gegangen" GridPane.columnIndex="2" GridPane.rowIndex="1" />
An dieser Vorgehensweise ist jedoch die unvollständige Trennung von Programmlogik und Präsen-
tation zu bemängeln (Inden 2015, S. 165f). Wir haben daher die Zuordnung in der initialize()
- Methode der Controller-Klasse vorgenommen.

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;

public class Attendance extends Application {


@Override
public void start(Stage stage) throws IOException {
FXMLLoader fxmlLoader = new FXMLLoader(Attendance.class.getResource("attendance.fxml"));
Scene scene = new Scene(fxmlLoader.load(), 320, 240);
stage.setTitle("Hello!");
stage.setScene(scene);
stage.show();
}

public static void main(String[] args) {


launch();
}
}

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();
}

static public Model getModel() {


return am;
}

public static void main(String[] args) {


launch();
}
}

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

13.5 Properties mit Änderungssignalisierung und automatischer Synchronisation


Unter der Bezeichnung Properties hat JavaFX beobachtbare Werte eingeführt. Sie werden realisiert
durch Klassen im Paket javafx.beans.property, deren Objekte einen Wert kapseln und registrierte
Interessenten informieren, wenn sich der Wert geändert hat oder ungültig geworden ist. JavaFX-
Komponenten (z. B. für die Steuerelemente und Zeichnungselemente in einer grafischen Bedien-
oberfläche) verwenden Property-Klassen für ihre öffentlich über get- bzw. set-Methoden zugängli-
chen Eigenschaften (z. B. Text in einem Eingabefeld, aktueller Wert eines Schiebereglers).
In der Dokumentation zu einer JavaFX-Komponente erscheinen deren Properties an prominenter
Stelle (ganz oben) und oft in enormer Anzahl, z. B. bei der Label-Komponente (vgl. Abschnitt
13.8.1):

JavaFX-Properties lassen sich miteinander verknüpfen, sodass eine automatische Synchronisation


ihrer Werte stattfindet. Sie sind nicht nur in Steuerelementen verwendbar, sondern in beliebigen
Klassen.

13.5.1 Basiswissen über Properties

13.5.1.1 Traditionelle JavaBean-Eigenschaften


Wir wissen seit Beginn des Kurses, dass in objektorientierten Programmen die Eigenschaften von
Objekten eine wichtige Rolle spielen:
• Sie repräsentieren die Zustandsdaten.
• Die letztlich zugrunde liegenden Instanzvariablen sind im Sinne der Datenkapselung vor
dem direkten Zugriff durch fremde Klassen geschützt.
• Über öffentliche Zugriffsmethoden, die oft als getter bzw. setter bezeichnet werden, sind
Lese und/oder Schreibzugriffe möglich.
Als JavaBeans werden in Java seit langer Zeit Klassen bezeichnet, die für ihre Eigenschaften öf-
fentliche Zugriffsmethoden mit genormten Namen besitzen:
• Auf die Einleitung durch get bzw. set folgt der Name der Eigenschaft (z. B.
getNumerator(), setNumerator()).
• Bei Eigenschaften mit dem Datentyp boolean wird die Abfragemethode mit is eingeleitet
(z. B. isSelected()).
Als Rückgabetyp für die setter hat sich void eingebürgert.1

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

Neben den Namenskonventionen besteht die JavaBeans-Komponententechnologie aus einem API


mit etlichen Typen im Paket java.beans. Man kann z. B. zu einer JavaBeans-Komponente mit Hilfe
der Klasse PropertyChangeSupport eine Liste von Beobachtern verwalten, die durch
PropertyChangeEvents über Veränderungen bei den Eigenschaften informiert werden sollen.
Es resultieren Komponenten, die sich gut für die Wiederverwendung und für die Unterstützung
durch Entwicklungswerkzeuge eignen. Typische Beispiele für JavaBeans-Komponenten sind die
Steuerelementen im GUI-Framework Swing. Weiterführende Informationen zur JavaBeans-
Komponententechnologie bietet z. B. das Java Tutorial (Oracle 2021).1
Traditionelle JavaBean-Eigenschaften machen einigen Aufwand bei der Verwaltung von Beobach-
tern, den man sich mit den Property-Klassen von JavaFX ersparen kann.

13.5.1.2 Property-Klassen von JavaFX


JavaFX-Komponenten (z. B. für die Steuerelemente in einer grafischen Bedienoberfläche) enthalten
Eigenschaften mit der Fähigkeit zum Versand von Veränderungsmitteilungen in Objekten von Pro-
perty-Klassen. Auch für JavaFX-Properties werden in der Regel Zugriffsmethoden (getter und set-
ter) unter Beachtung der traditionellen Namenskonventionen für JavaBeans-Eigenschaften imple-
mentiert. Zusätzlich bietet eine JavaFX-Komponente zu einem Property-Objekt eine Zugriffsme-
thode mit einer Referenz auf dieses Objekt als Rückgabe. Diese Rückgabe wird z. B. benötigt, um
bei einem Property-Objekt einen Beobachter für Wertveränderungen zu registrieren.
Für eine JavaFX-Komponentenklasse gelten folgende Namensregeln:
• Die Instanzvariable zu einem Property-Objekt trägt einen Namen nach den Camel Casing -
Regeln (siehe Abschnitt 3.3.2), der anschließend als Eigenschaftsname bezeichnet werden
soll (z. B. caretPosition).
• In den Namen der Methoden für den lesenden bzw. schreibenden Zugriff folgt (wie bei Ja-
vaBeans) auf get bzw. set der Eigenschaftsname mit einem groß geschriebenen Anfangs-
buchstaben (z. B. getCaretPosition() bzw. setCaretPosition()).
• Im Namen der Methode zum Erfragen der Property-Referenz folgt auf den Eigenschaftsna-
men der Zusatz Property (z. B. caretPositionProperty).
Im Paket javafx.beans.property befinden sich für die Datentypen boolean, double, float, int,
long, Object, String, List<E>, Map<K,V> und Set<E> jeweils vier Property-Klassen, z. B. beim
Typ int:
• IntegerProperty
Diese abstrakte Klasse eignet sich als Datentyp für int-Properties.
• SimpleIntegerProperty
Diese konkrete, von IntegerProperty abstammende Klasse wird zum Instanziieren von int-
Properties verwendet, wenn der Lese- und der Schreibzugriff möglich sein sollen.

• 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

Ein IntegerProperty-Objekt beherrscht u. a. die folgenden Methoden:


• public int get()
Diese Methode liefert den gekapselten Wert vom Typ int.
• public void set(int value), public void setValue(java.lang.Number value)
Mit dieser Methode verändert man den gekapselten Wert.
• public String getName()
Über diese Methode erfährt man den Namen der Property.
• public Object getBean()
JavaFX-Properties können per Konstruktor einem Objekt (einer Komponente) zugeordnet
werden (siehe unten) und per getBean() nach diesem Objekt befragt werden. Nicht zuge-
ordnete Properties antworten mit der Rückgabe null.
Die Methoden addListener() und removeListener() zum Registrieren von Beobachtern werden im
Abschnitt 13.5.2 behandelt, und über die Methoden bind(), unbind(), bindBidirectional() und
unbindBidirectional() zur automatischen Synchronisation von Property-Objekten informiert der
Abschnitt 13.5.3.
Wir betrachten nun die Verwendung von JavaFX-Properties im Rahmen einer simplen Klasse
namens Person, die folgende Properties enthält:
Abschnitt 13.5 Properties mit Änderungssignalisierung und automatischer Synchronisation 645

• 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);

public Person(String first, String last, int a) {


firstName.set(first);
lastName.set(last);
age.set(a);
}
. . .
}
kommen Property-Konstruktoren mit drei Parametern zum Einsatz:
• Im ersten Parameter (Object bean) wird festgelegt, zu welchem Objekt die Property gehört.
Im Beispiel wird jeweils das gerade erzeugte Person-Objekt eingetragen.
• Im zweiten Parameter (String name) erhält die Property einen Namen.
• Im dritten Parameter wird der initiale Wert festgelegt.
Es werden die bei JavaBean-Eigenschaften üblichen Zugriffsmethoden unter Beachtung der im Ab-
schnitt 13.5.1.1 beschriebenen Namensregeln erstellt, z. B. für die Property firstName:
public final String getFirstName() {
return firstName.get();
}
public final void setFirstName(String first) {
firstName.set(first);
}
Durch das Finalisieren wird verhindert, dass die Methoden in abgeleiteten Klassen überschrieben
werden.
Die folgende Methode liefert eine Referenz auf das StringProperty-Objekt firstName:
public final StringProperty firstNameProperty() {
return firstName;
}
Eine Property-Referenz wird benötigt, um ...
• Veränderungs- bzw. Invalidierungs-Listener zu registrieren (siehe Abschnitt 13.5.2)
• Bindungen vorzunehmen (siehe Abschnitt 13.5.3).
Das Beispiel ist nicht besonders gelungen, weil sich der Vorname einer Person nur selten ändert.
Bei der Read-Only - Property id wird keine Setter-Methode implementiert, und der Referenz-
Getter idProperty() liefert unter Verwendung der ReadOnlyIntegerWrapper-Methode
getReadOnlyProperty() ein Objekt der Klasse ReadOnlyIntegerProperty, das automatisch mit
der intern verfügbaren IntegerProperty synchronisiert wird:
646 Kapitel 13 GUI-Programmierung mit JavaFX

public final int getId() {


return id.get();
}
public final ReadOnlyIntegerProperty idProperty() {
return id.getReadOnlyProperty();
}

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.

13.5.1.3 Vermeidung von überflüssigen Objektkreationen


Für die Komfortfunktionen der JavaFX-Properties muss man mit zahlreichen Objekt-Kreationen
bezahlen. Wenn das zum Problem wird, lassen sich Objektkreationen mit einem simplen Trick hin-
auszögern und vielfach vermeiden. Man wickelt zu einer JavaFX-Property die Speicherung sowie
Lese und Schreibzugriffe wie bei einer traditionellen JavaBean-Eigenschaft zunächst über eine ge-
wöhnliche Instanzvariable ab. Das Property-Objekt wird erst dann erstellt, wenn eine Referenz auf
dieses Objekt angefordert wird.
Anschließend wird das Verfahren mit einer abgespeckten Version der im Abschnitt 13.5.1.2 vorge-
stellten Klasse Person vorgeführt:
import javafx.beans.property.*;

public class Person {


private StringProperty firstName;
private String firstNameB = "";

public final String getFirstName() {


if (firstName == null)
return firstNameB;
else
return firstName.get();
}
Abschnitt 13.5 Properties mit Änderungssignalisierung und automatischer Synchronisation 647

public final void setFirstName(String first) {


if (firstName == null)
firstNameB = first;
else
firstName.set(first);
}

public final StringProperty firstNameProperty() {


if (firstName == null) {
firstName = new SimpleStringProperty(this, "firstName", firstNameB);
System.out.println("Property created");
}
return firstName;
}
}
Erst ein Aufruf der Methode firstNameProperty() führt zur Objektkreation:
Quellcode Ausgabe
class LacyPropertyCreation { Vorname: Otto
public static void main(String[] args) {
Person p = new Person(); Property created
p.setFirstName("Otto"); Vorname geändert in: Otti
System.out.printf("Vorname: %s\n\n", p.getFirstName());
p.firstNameProperty().addListener((obs, old, nev) ->
System.out.println("Vorname geändert in: " + nev));
p.setFirstName("Otti");
}
}

13.5.2 Invalidierungs- und Veränderungmitteilungen


Während bei einer JavaBean-Eigenschaft die Verwaltung von Interessenten für Veränderungsmit-
teilungen einigen Aufwand verursacht (siehe z. B. Epple 2015, Abschnitt 3.1), sind JavaFX-
Properties für diese Aufgabe gut vorbereitet:
• Alle JavaFX-Properties implementieren das Interface Observable. Mit addListener()bzw.
removeListener() kann ein Objekt aus einer Klasse, die das Interface InvalidationListener
erfüllt, in die Liste der Interessenten für Invalidierungsnachrichten aufgenommen bzw. dar-
aus entfernt werden. Die InvalidationListener-Methode invalidated() wird aufgerufen,
wenn die beobachtete Eigenschaft ungültig geworden ist und erhält dabei als Aktualparame-
ter eine Referenz auf die beobachtete Eigenschaft.
• Alle JavaFX-Properties implementieren das von Observable abstammende Interface
ObservableValue<T>. Mit addListener() bzw. removeListener() kann ein Objekt aus ei-
ner Klasse, die das Interface ChangeListener<? super T> erfüllt, in die Liste der Interes-
senten für Veränderungsnachrichten aufgenommen bzw. daraus entfernt werden. Die
ChangeListener<T> - Methode changed() wird bei jeder Wertveränderungen aufgerufen
und erhält dabei als Aktualparameter eine Referenz auf die beobachtete Eigenschaft sowie
den alten und den neuen Wert:
public void changed (ObservableValue<? extends T> observable, T oldVal, T newVal)
Bei den Property-Klassen zur Verwaltung eines primitiven Werts ist T notwendigerweise
ein Referenztyp. Die Klasse SimpleIntegerProperty implementiert z. B. das Interface
ObservableValue<Number>.
Ob ein Interessent als Invalidation- oder als Change-Listener behandelt wird, hängt also vom im-
plementierten Interface ab.
648 Kapitel 13 GUI-Programmierung mit JavaFX

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);
}
}

13.5.3 Automatische Synchronisation von Property-Objekten


Ein Invalidation- bzw. Change-Listener kann in seiner Methode invalidated() bzw. changed()
beliebige Aktionen ausführen. Oft soll aber auf eine Wertveränderung bei einer Property lediglich
mit einer Wertanpassung bei anderen Properties reagiert werden. Das lässt sich durch eine uni- oder
bidirektionale Bindung leichter realisieren als durch Listener:
• Ein Property-Objekt kann uni- oder bidirektional mit einem anderen Property-Objekt vom
gleichen Typ verbunden werden.
• Ein Property-Objekt kann an ein Berechnungsergebnis gebunden werden, zu dem mehrere
Objekte vom Typ ObservableValue<T> (z. B. mehrere Property-Objekte) beitragen.
Abschnitt 13.5 Properties mit Änderungssignalisierung und automatischer Synchronisation 649

13.5.3.1 Uni- und bidirektionale Synchronisation von Property-Objekten


JavaFX-Properties können aneinander gebunden werden, sodass eine (uni- oder bidirektionale)
Synchronisation ihrer Werte stattfindet, ohne dass sich Programmierer abmühen müssen. Alle Ja-
vaFX-Properties implementieren das von ReadOnlyProperty<T> und WritableValue<T> abge-
leitete Interface Property<T>, das folgende Methoden zur Unterstützung der Datenbindung
vorschreibt:
• public void bind(ObservableValue<? extends T> observable)
Mit dieser Methode wird das angesprochene Property-Objekt unidirektional an ein Objekt
vom Typ ObservableValue<? extends T> gebunden (z. B. an ein anderes Property-
Objekt), das als Quelle fungiert. Das angesprochene (und gebundene) Objekt übernimmt so-
fort den Wert der Quelle. Direkte Schreibzugriffe auf das gebundene Objekt sind verboten.
• public void unbind()
Mit dieser Methode wird eine unidirektionale Verbindung aufgehoben.
• public void bindBidirectional(Property<T> other)
Mit dieser Methode wird ein Property-Objekt bidirektional mit einem anderen Property-
Objekt vom selben Typ verbunden. Das angesprochene Objekt übernimmt sofort den Wert
des Parameterobjekts.
• public void unbindBidirectional(Property<T> other)
Mit dieser Methode wird eine bidirektionale Verbindung aufgehoben.
• public boolean isBound()
Diese Methode informiert darüber, ob das angesprochene Property-Objekt gebunden ist.
Im folgenden Programm werden zwei Objekte vom Typ SimpleIntegerProperty bidirektional ver-
bunden:
import javafx.beans.property.*;

class PropBiSync {
public static void main(String[] args) {
IntegerProperty i1 = new SimpleIntegerProperty(1);
IntegerProperty i2 = new SimpleIntegerProperty(2);

i1.addListener((obs, old, nev) -> System.out.println("i1 nun gleich "+i1.get()));


i2.addListener((obs, old, nev) -> System.out.println("i2 nun gleich "+i2.get()));

i1.bindBidirectional(i2);

System.out.println("\nÄnderungen nach bidirektionaler Verbindung:");


i1.set(11);
i2.set(22);

System.out.println("\nBidirektionale Verbindung aufgehoben:");


i1.unbindBidirectional(i2);
i1.set(111);
}
}
Über die Change-Listener kann die (ausbleibende) Fortpflanzung von Änderungen beobachtet wer-
den:
650 Kapitel 13 GUI-Programmierung mit JavaFX

i1 nun gleich 2

Änderungen nach bidirektionaler Verbindung:


i1 nun gleich 11
i2 nun gleich 11
i2 nun gleich 22
i1 nun gleich 22

Bidirektionale Verbindung aufgehoben:


i1 nun gleich 111

In der nächsten Programmvariante sorgt eine unidirektionale Verbindung


i1.bind(i2);
dafür, dass nur Wertveränderungen (i2 → i1) propagiert werden:
import javafx.beans.property.*;

class PropUniSync {
public static void main(String[] args) {
IntegerProperty i1 = new SimpleIntegerProperty(1);
IntegerProperty i2 = new SimpleIntegerProperty(2);

i1.addListener((obs, old, nev) -> System.out.println("i1 nun gleich "+i1.get()));


i2.addListener((obs, old, nev) -> System.out.println("i2 nun gleich "+i2.get()));

i1.bind(i2);
System.out.println("\nÄnderung nach unidirektionaler Verbindung:");
// i1.set(11); //verboten
i2.set(22);

System.out.println("\nUnidirektionale Verbindung aufgehoben:");


i1.unbind();
i2.set(222);
}
}
Änderungen bei i2 werden auf i1 übertragen:
i1 nun gleich 2

Änderung nach unidirektionaler Verbindung:


i1 nun gleich 22
i2 nun gleich 22

Unidirektionale Verbindung aufgehoben:


i2 nun gleich 222

Direkte Schreibzugriffe auf die gebundene Property i1 führen zu einem Ausnahmefehler.

13.5.3.2 Property-Objekt an ein Berechnungsergebnis binden


Es ist möglich, den Wert eines Property-Objekts an ein Berechnungsergebnis zu binden, zu dem
mehrere Objekte vom Typ ObservableValue<T> (z. B. mehrere Property-Objekte) beitragen. Dazu
verwendet man ein Objekt einer Binding-Klasse, die aufgrund ihres Stammbaums und ihrer Inter-
face-Verpflichtungsverträge die Kompetenz besitzt, aus mehreren Quellen, die auch als ihre Abhän-
gigkeiten (engl.: dependencies) bezeichnet werden, einen Funktionswert zu berechnen. Im Paket
javafx.beans.binding befinden sich für die Funktionswertdatentypen boolean, double, float, int,
long, Object, String, List<E>, Map<K,V> und Set<E> die Klassen BooleanBinding,
DoubleBinding, FloatBinding, IntegerBinding, LongBinding, ObjectBinding, StringBinding,
ListBinding, MapBinding und SetBinding, die alle das Interface Binding<T> implementieren.
Abschnitt 13.5 Properties mit Änderungssignalisierung und automatischer Synchronisation 651

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).

13.5.3.2.1 High-Level Binding-API


Das High-Level - Binding-API in JavaFX deckt häufige Anwendungsfälle auf bequeme Weise ab.
Zur Demonstration greifen wir die Klasse Person aus dem Abschnitt 13.5.1.2 wieder auf und er-
gänzen die Eigenschaft yearOfBirth:
private IntegerProperty yearOfBirth = new SimpleIntegerProperty(this, "yearOfBirth", -99);
private IntegerProperty age = new SimpleIntegerProperty(this, "age", -99);

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

• Statische Methoden der Klasse Bindings


Ein Binding-Objekt, an das eine Property gebunden werden kann, lässt sich auch über stati-
sche Methoden der Klasse Bindings erstellen, z. B.:1
age.bind(Bindings.subtract(Year.now().getValue(), yearOfBirth));

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

Alter geändert auf 27

13.5.3.2.2 Low-Level Binding-API


Nicht immer genügen zur Definition einer Bindung die im High-Level - Binding-API unterstützten
Verknüpfungen der Quellen durch arithmetische Operationen. Mit dem Low-Level Binding-API
lässt sich jede beliebige Bindungsdefinition realisieren. Man benötigt eine Binding-Klasse mit pas-
sender Berechnung des Funktionswerts aus den Abhängigkeiten und muss dazu aus einer Basisklas-
se mit dem korrekten Funktionswertdatentyp eine eigene Klasse ableiten, um dort die Methode
computeValue() zu überschreiben.
Wir betrachten als Beispiel eine von IntegerBinding abstammende Klasse, deren Objekte für zwei
beobachtete IntegerProperty-Quellen den größten gemeinsamen Teiler als Funktionswert liefern.
Einem häufig verwendeten Entwurfsmuster folgend (siehe z. B. Sharan 2015, S. 77ff) realisieren
wir die Ableitung als anonyme Klasse. Bei der Instanzierung eines Objekts aus der anonymen Bin-
dungsklasse muss die IntegerBinding-Methode bind() aufgerufen werden, um Abhängigkeiten zu
registrieren:
protected final void bind(Observable... dependencies)
Zur Initialisierung neuer Objekte können bei einer anonymen Klasse mangels Klassenname keine
Konstruktoren verwendet werden. Als Ersatz bieten sich die ansonsten selten benötigten Instanzini-
tialisierer an (vgl. Abschnitt 4.4.4).

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

Das folgende Beispielprogramm definiert und nutzt die anonyme IntegerBinding-Ableitung im


Rahmen der main() - Methode:
import javafx.beans.binding.IntegerBinding;
import javafx.beans.property.*;

class LowLevelBinding {
public static void main(String[] args) {
IntegerProperty i1 = new SimpleIntegerProperty(1);
IntegerProperty i2 = new SimpleIntegerProperty(8);

IntegerBinding ggtBnd = new IntegerBinding() {


// Instanzinitialisierer
{
bind(i1, i2);
}
@Override
protected int computeValue() {
int rest, a = i1.get(), b = i2.get();
do {
rest = a % b;
a = b;
b = rest;
} while (rest > 0);
return Math.abs(a);
}
};

IntegerProperty ggt = new SimpleIntegerProperty();


ggt.bind(ggtBnd);
System.out.println("Initialer Wert: " + ggt.getValue());
ggt.addListener((obs, old, nev) ->
System.out.println(" Neuer GGT: " + nev));

System.out.println("Automatische Wertanpassung bei ggt:");


i1.set(22);
i1.set(12);
i1.set(24);
i1.set(32);
i1.set(33);
}
}

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

13.5.3.3 Beobachtbare Listen


Eine beobachtbare Liste, die bei einer Änderung ihrer Zusammensetzung eine Mitteilung an re-
gistrierte Beobachter versendet, haben wir schon in einem JavaFX-Beispielprogramm als Datenmo-
dell für ein ListView<E> - Steuerelement verwendet und dabei als „technisches Glanzstück“ be-
zeichnet (siehe Abschnitt 13.2). In diesem Abschnitt folgen noch einige ergänzenden Erläuterungen
zu beobachtbaren Listen.
Diese implementieren das Interface ObservableList<E>, das sich wie alle anderen im Zusammen-
hang mit beobachtbaren Kollektionen relevanten Typen im Paket javafx.collections befindet. Mit
beobachtbaren Mengen (vom Typ ObservableSet<E>) und beobachtbaren Abbildungen (vom Typ
ObservableMap<K,V>) können wir uns aus Zeitgründen nicht beschäftigen. Eine ausführliche
Beschreibung von beobachtbaren Kollektionen (Listen, Mengen und Abbildungen) findet sich z. B.
bei Sharan (2015, S. 83ff).
Es fällt z. B. sehr leicht fällt, ein ListView<E> - Steuerelement mit einem ObservableList<E> -
Objekt zu verbinden, was meist im ListView<E> - Konstruktor geschieht, z. B.:
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);
Die im Beispiel verwendete Klasse SortedList<String> implementiert die Schnittstelle
ObservableList<String>, und das ListView<String> - Steuerelement beobachtet automatisch alle
Änderungen der Liste. Es aktualisiert eigenständig und spontan seine Anzeige, wenn ...
• ein Listenelement aufgenommen oder entfernt wird,
• wenn sich ein Listenelement ändert, wenn also die Zeichenfolge zu einem Listenelement er-
setzt wird.
Selbstverständlich kommen neben ListView<E> - Steuerelementen noch viele andere Interessenten
für Listenereignisse in Frage, und das API zur Signalisierung von solchen Ereignissen hält einige
Details bereit. Wer momentan nicht daran interessiert ist, sollte sich den Rest von Abschnitt
13.5.3.3 vorläufig ersparen.
Das Interface ObservableList<E> erweitert die Schnittstellen List<E> und Observable,

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();

13.5.3.3.1 Zusammensetzung und Anordnung der Listenelemente beobachten


Um von der eben erstellten Liste vom Typ ObservableList<String> über Veränderungen im Detail
informiert zu werden, registriert man per addListener() ein Objekt aus einer das Interface
ListChangeListener<String> implementierenden Klasse. Im folgenden Codesegment wird dazu
eine anonyme Klasse definiert:
ols.addListener(new ListChangeListener<String>() {
@Override
public void onChanged(ListChangeListener.Change<? extends String> c) {
int change = 0;
while (c.next()) {
change++;
if (c.wasRemoved())
System.out.println("Removed-Veränderung " + change +
", entfernt: " + c.getRemoved());
if (c.wasAdded())
System.out.println("Added-Veränderung " + change +
", betroffen: von " + c.getFrom() + " bis " + (c.getTo()-1) +
", ergänzt: " + c.getAddedSubList());
}
}
});

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

• public boolean wasUpdated()


Damit eine beobachtbare Liste mit beobachtbaren Elementen über Wertveränderungen
berichten kann, müssen spezielle Voraussetzungen bestehen (siehe Abschnitt 13.5.3.3.2).
Welche Listenelemente von einer Veränderung betroffen sind, ist über die folgenden Change<E> -
Methoden zu ermitteln:
• public int getFrom()
Liefert den Startindex (inklusive)
• public int getTo()
Liefert den Endindex (exklusive)
Weitere Change<E> - Methoden:
• public ObservableList<E> getList()
Liefert eine Referenz auf die beobachtete Liste
• public int getAddedSize()
Liefert die Anzahl der ergänzten Elemente
• public List<E> getAddedSubList()
Liefert eine Liste mit den ergänzten Elementen
• protected int[] getPermutation()
Der gelieferte int-Array beschreibt eine Permutation.
• public int getRemovedSize()
Liefert die Anzahl der entfernten Elemente
• public List<E> getRemoved()
Liefert eine Liste mit den entfernten Elementen
Aus den im bisherigen Verlauf des Abschnitts 13.5.3.3 präsentierten Codesegmenten resultiert ein
Objekt vom Typ ObservableList<String>, bei dem ein Interessent für Veränderungen registriert
ist. Nun werden an der beobachtbaren Liste einige Veränderungen vorgenommen:
System.out.println("Elemente aufnehmen:");
ols.addAll("A", "B", "C", "D", "E", "F");

System.out.println("\nHintereinander liegende Elemente entfernen:");


ols.removeAll("D", "E", "F");

System.out.println("\nEin Element ersetzen:");


ols.set(0, "A1");

System.out.println("\nNicht hintereinander liegende Elemente entfernen:");


ols.removeAll("A1", "C");

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]

Hintereinander liegende Elemente entfernen:


Removed-Veränderung 1, entfernt: [D, E, F]
Abschnitt 13.5 Properties mit Änderungssignalisierung und automatischer Synchronisation 657

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]

13.5.3.3.2 Wertveränderungen bei Listenelementen beobachten


Sollen auch Wertveränderungen bei einzelnen Listenelementen beobachtet werden, dann muss für
jedes Element ein Array mit Änderungsmeldern vom Typ Observable benannt werden, die auf
Veränderungen beim Element abgehört werden sollen. Um Update-Ereignisse feuern zu können,
überwacht eine beobachtbare Liste für jedes ihrer Elemente einen Array von Observable-Objekten.
Diese komplexe, aber unvermeidliche Konstruktion wird von Sharan (2015, S. 93) so erklärt: Eine
Liste enthält Elemente mit Referenztyp, und jedes Element kann an diversen Stellen im Programm
verändert werden, ohne dass die Liste davon erfährt. Daher muss die Liste zu jedem ihrer Elemente
alle Objekte kennen, die über eine Änderung des Listenelements informieren können.
Den zu einem Listenelement gehörigen Observable-Array erfragt eine beobachtbare Liste bei ei-
nem Objekt, das die Schnittstelle Callback<E,Observable[]> implementiert. Eine beobachtbare Liste
kann nur dann Update-Ereignisse liefern, wenn der FXCollections-Fabrikmethode ein passendes
Objekt übergeben worden ist, z. B.:
public static <E> ObservableList<E> observableArrayList(Callback<E, Observable[]> extractor)
Das Objekt mit dem Parameternamen extractor beherrscht eine Methode namens call(), die als Pa-
rameter ein Listenelement erhält und den zugehörigen Array mit Objekten vom Typ Observable zu
liefern hat. Diese Methode call() wird von der beobachtbaren Liste aufgerufen, sobald ein neues
Element eingefügt wird.
Wenn die Listenelemente selbst beobachtbar sind, ist der erforderliche Observable-Array zu einem
Element leicht zu beschaffen, indem das Element in einen einelementigen Array vom Typ Obser-
vable[] verpackt wird. Wir ersetzen im Beispiel aus dem Abschnitt 13.5.3.3.1 die Klasse String
durch die Klasse StringProperty. Das Callback<E,Observable[]> - Parameterobjekt für die
FXCollections-Methode observableArrayList() wird durch einen Lambda-Ausdruck realisiert, der
sein Argument in einen einelementigen, per Initialisierungsliste erstellten Observable[] - Array
verpackt:
ObservableList<StringProperty> ols =
FXCollections.observableArrayList(e -> new Observable[] {e});
Im folgenden Beispielprogramm erhält das ObservableList<StringProperty> - Objekt nach dem
im Abschnitt 13.5.3.3.1 beschriebenen Strickmuster einen ListChangeListener<StringProperty>.
Diesmal werden in der Methode onChanged() auch Update-Ereignisse verarbeitet. Man erkennt sie
am Rückgabewert true der wasUpdated() - Anfrage an das in onChanged() verfügbare Parame-
terobjekt vom Typ ListChangeListener.Change<E>:
658 Kapitel 13 GUI-Programmierung mit JavaFX

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());
}
}
}
});

StringProperty spa = new SimpleStringProperty("A");


StringProperty spb = new SimpleStringProperty("B");

System.out.println("\nElemente aufnehmen:");
ols.addAll(spa, spb);

System.out.println("\nEin Element ändern:");


ols.get(1).set("B1");
}
}
Wenn ein Listenelement einen neuen Wert erhält, dann feuert die beobachtbare Liste ein Update-
Ereignis:
Elemente aufnehmen:
Ergänzt: [StringProperty [value: A], StringProperty [value: B]]

Ein Element ändern:


Neuer Wert von Element 1: B1

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

Zu vielen direkten Event-Ableitungen (z. B. InputEvent) sind weitere Spezialisierungen vorhan-


den:

java.lang.Object

javafx.event.Event

javafx.scene.input.InputEvent javafx.event.ActionEvent javafx.stage.WindowEvent

KeyEvent MouseEvent TouchEvent

Manche Ereignisklassen sind zu allgemein, um ein konkretes Geschehen zu beschreiben. Ein


MouseEvent liegt z. B. vor, wenn ...
• eine Maustaste gedrückt wurde,
• eine Maustaste losgelassen wurde,
• eine Maustaste geklickt wurde,
• die Maus bewegt wurde.
Um ein Ereignis genauer zu beschreiben, verwendet JavaFX die generische Klasse
EventType<E extends Event>
Soll z. B. bei einem Knoten im Szenegraphen ein Ereignisbehandlungsobjekt mit Hilfe der Methode
addEventHandler() registriert werden, dann ist ein Parameterobjekt vom Typ EventType<E
extends Event> zu übergeben (siehe Abschnitt 13.6.2.2):
public final <E extends Event> void addEventHandler(EventType<E> eventType,
EventHandler<? super E> eventHandler)
Im Konstruktor zu einer Konkretisierung der generischen Klasse EventType<E extends Event>
sind anzugeben:
• ein Objekt des übergeordneten Typs
Beim übergeordneten Typ handelt es sich nicht um die Basisklasse, sondern um eine andere
EventType<E> - Konkretisierung. Eine Ausnahme macht die allgemeinste EventType<E>
- Klasse mit dem „übergeordneten Typ“ null.
• ein Name
Meist verwendet man vordefinierte EventType<E> - Objekte, die über öffentliche Konstanten der
Ereignisklassen angesprochen werden können. Ein Beispiel ist die Konstante MOUSE_PRESSED
in der Klasse MouseEvent:
660 Kapitel 13 GUI-Programmierung mit JavaFX

public static final EventType<MouseEvent> MOUSE_PRESSED =


new EventType<MouseEvent>(MouseEvent.ANY, "MOUSE_PRESSED");
Wenn in einer Ereignisklasse E (z. B. MouseEvent) Spezialisierungen vorhanden sind, dann ...
• wird durch das Feld ANY ein EventType<E> - Objekt mit der allgemeinen Ereignisvarian-
te angesprochen (z. B. MouseEvent.ANY)
• werden durch weitere Felder EventType<E> - Objekte zu speziellen Ereignisvarianten an-
gesprochen (z.B. MouseEvent.PRESSED, MouseEvent.RELEASED,
MouseEvent.CLICKED, MouseEvent.MOVED).
In der folgenden Abbildung sind Abhängigkeitsbeziehungen zwischen wichtigen EventType<E> -
Konkretisierungen zu sehen, keine Vererbungsbeziehungen:
Event.ANY

InputEvent.ANY ActionEvent.ACTION WindowEvent.ANY

KeyEvent.ANY MouseEvent.ANY TouchEvent.ANY

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):

Im VBox-Wurzelknoten sind ein Button- und ein Label-Objekt enthalten.

13.6.2.1 Top-Down - Route


Ist z. B. das Label-Objekt das Ziel für ein Mausereignis, dann wird das Ereignis zunächst im Top-
Down - Zweig seiner Route nacheinander dem Stage-, dem VBox- und dem Label-Objekt angebo-
ten
Abschnitt 13.6 Ereignisse 661

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();
};

Es ist in JavaFX üblich, Funktionsobjekte zur Ereignisbehandlung per Lambda-Ausdruck zu reali-


sieren. Im Beispiel dient eine statische Methode dazu, den Lambda-Code kurz zu halten und Wie-
derholungen zu vermeiden.
Ein JavaFX-Ereignis hat
• einen Typ (siehe Abschnitt 13.6.1)
• ein Ziel
Damit ist das unmittelbar vom Ereignis betroffene Objekt gemeint. Bei einem
MOUSE_PRESSED-Ereignis wird derjenige Knoten im Szenegraphen zum Ziel, über dem
sich beim Tastendruck der Mauszeiger befand.
• eine Quelle
Als Quelle wird gerade angesprochene Knoten auf der Ereignis-Route bezeichnet.
662 Kapitel 13 GUI-Programmierung mit JavaFX

Nach einem Mausklick auf das Label-Objekt im Beispielprogramm erscheinen nacheinander drei
Informations-Dialoge:

Über die Window-Methode removeEventFilter() kann ein Ereignisfilter entfernt werden.

13.6.2.2 Bottom-Up - Route


Beim Bottom-Up - Zweig einer Ereignisroute spricht man auch von der aufperlenden Phase (engl.:
bubbling phase).
Registrierte Ereignisbehandler (engl.: event handler), die wie Ereignisfilter das Interface
EventHandler implementieren müssen, werden über das Ereignis informiert. Über ein Ereignisbe-
handlungsobjekt auf der Bottom-Up - Route kann ein Elternknoten eine ergänzende gemeinsame
Ereignisbehandlung für mehrere Kindknoten durchführen.
Zum Registrieren eines Ereignisbehandlungsobjekts dient die generische Window-Methode
addEventHandler():
public final <E extends Event> void addEventHandler(EventType<E> eventType,
EventHandler<? super E> eventHandler)
Im Beispielprogramm wird beim Stage-, beim VBox- und beim Label-Objekt ein Handler für das
vom öffentlichen Feld MOUSE_PRESSED der Klasse MouseEvent (im Paket ja-
vafx.scene.input)
stage.addEventHandler(MouseEvent.MOUSE_PRESSED, event -> presentEvent(event));
root.addEventHandler(MouseEvent.MOUSE_PRESSED, event -> presentEvent(event));
lblText.addEventHandler(MouseEvent.MOUSE_PRESSED, event -> presentEvent(event));
referenzierte Objekt der Klasse EventType<MouseEvent> registriert (siehe Abschnitt 13.6.2.1).
Abschnitt 13.6 Ereignisse 663

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.

13.6.3 Ereignis-Properties und Bequemlichkeitsmethoden


Für viele Ereignisse (z. B. ActionEvent) bieten JavaFX-Steuerelementklassen Properties an (z. B.
onAction in der Klasse ButtonBase), und die zugehörigen Setter (z. B. setOnAction() in der Klas-
se ButtonBase) vereinfachen als Bequemlichkeitsmethoden die Ereignisbehandlung, wobei im Hin-
tergrund die Methode setEventHandler() aufgerufen wird. In dem seit dem Abschnitt 13.6.2 ver-
wendeten Beispielprogramm wird auf sehr simple Weise zum Button-Objekt eine ActionEvent-
Behandlung eingerichtet:
Button btnAdd = new Button("Zählerstand erhöhen");
btnAdd.setOnAction(event -> lblText.setText(lblPrefix + ++numClicks));

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"};

ObservableList<String> persons = FXCollections.observableArrayList(anwesend);


SortedList<String> perSorted = new SortedList<>(persons,
Comparator.naturalOrder());
lvPersons = new ListView<>(perSorted);

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()));

Pane root = makeGridPane();

primaryStage.setTitle("Anwesenheitskontrolle");
primaryStage.setScene(new Scene(root, 450, 200));
primaryStage.setMinWidth(400);
primaryStage.setMinHeight(150);
primaryStage.show();
}

Von den drei Optionen zur Konfiguration der Layoutmanager


• Java-Anweisungen
• Direkte FXML-Deklaration
• Indirekte FXML-Deklaration via Scene Builder

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 root = new GridPane();


root.setPadding(new Insets(dist, dist, dist, dist));
root.setHgap(dist); root.setVgap(dist);

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.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.

13.7.1.2 GridPane-Eigenschaften zur Gestaltung von Abständen


Ein GridPane-Container kann über seine Eigenschaften (Property-Objekte) padding, hgap und
vgap dafür sorgen, dass seine Kindelemente zum Container-Rand sowie untereinander einen ein-
stellbaren Abstand halten:
• padding
Um zu verhindern, dass die in einem Container enthaltenen Elemente gegen den Rand sto-
ßen, kann mit der Region-Methode setPadding() unter Verwendung eines Parameterobjekts
der Klasse Insets ein freizuhaltender Innenrahmen für den Container vereinbart werden,
z. B.:
root.setPadding(new Insets(dist, dist, dist, dist));
• hgap, vgap
Über die Methoden setHgap() und setVgap() lässt sich ein Abstand zwischen den Elemen-
ten in einem GridPane-Container vereinbaren, z. B.:
root.setHgap(dist); root.setVgap(dist);
Seit Java 8 Update 60 passt JavaFX unter Windows Positions- und Größenangaben automatisch an
die Bildschirmauflösung an. Alle Positions- und Größenangaben verwenden die Maßeinheit Pixel
666 Kapitel 13 GUI-Programmierung mit JavaFX

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);

13.7.1.3 Anzeigeeinstellungen für Kindelemente (layout constraints)


Ein GridPane-Layoutmanager unterstützt mehrere Anzeigeeinstellungen für die enthaltenen Kno-
ten, die über statische GridPane-Methoden gesetzt werden. So wird z. B. mit der Methode
setVgrow() vereinbart, welches Steuerelement bei einer vertikalen Größenzunahme des Containers
profitieren soll:
public static void setVgrow (Node child, Priority value)
Der natürliche Ansprechpartner für einen derartigen Darstellungswunsch ist eher das konkret be-
troffene Layoutmanager-Objekt als seine Klasse. Nach Horstmann (2014, S. 83) ist das ungewöhn-
liche, in vielen Layoutmanager-Klassen von JavaFX realisierte Muster entstanden, um die Verarbei-
tung von Layout-Deklarationen in FXML-Dateien unterstützen zu können.
In der API-Dokumentation werden die über statische Layoutmanager-Methoden konfigurierbaren
Anzeigeeinstellungen für Kindelemente als layout constraints bezeichnet. Ein Kindelement sam-
melt die ihm vom Layoutmanager zugeordneten Anzeigeeinstellungen in einer Hashtabelle (Typ
ObservableMap<Object,Object>, abrufbar über die Methode getProperties()). Ein Layoutmana-
ger liest und berücksichtigt die Anzeigeeinstellungen der enthaltenen Knoten.1
Technisch sind die layout constraints durch Objekte realisiert, die von der abstrakten Basisklasse
ConstraintsBase abstammen.
Die Flexibilität des GridPane-Layoutmanagers artikuliert sich durch eine umfangreiche Liste von
Anzeigeeinstellungen für Kindelemente:
• columnIndex, rowIndex
Bei diesen Einstellungen geht es um die Spalten- bzw. Zeilenposition eines Kindelements,
z. B.:
root.getChildren().add(lblName);
GridPane.setColumnIndex(lblName, 0);
GridPane.setRowIndex(lblName, 0);
Die initiale Positionierung eines Kindelements wird meist schon bei der Aufnahme durch
die GridPane-Instanzmethode add() vorgenommen, z. B.:
root.add(lblName, 0, 0);

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));

13.7.1.4 Dynamische Platzverteilung auf mehrere Spalten bzw. Zeilen


Wenn bei einer horizontalen bzw. vertikalen Größenzunahme des umgebenden Containers mehrere
Spalten bzw. Zeilen profitieren sollen, und dabei feste Größenrelationen beibehalten werden sollen,
dann verwendet man Objekte aus der Klasse ColumnConstraints bzw. RowConstraints. In der
folgenden Variante des Beispielprogramms zur Anwesenheitskontrolle wachsen bei einer horizonta-
len Größenzunahme alle drei Spalten, wobei die prozentuale Größenverteilung 25:50:25 beibehalten
wird:
668 Kapitel 13 GUI-Programmierung mit JavaFX

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

13.7.3 HBox und VBox


Ein Layoutmanager aus der Klasse HBox bzw. VBox ordnet seine Kindelemente neben- bzw. un-
tereinander an. Er unterstützt die folgenden Anzeigeeinstellungen (layout constraints, siehe Ab-
schnitt 13.7.1.3) für Kindelemente, die über statische Methoden verwaltet werden:
• hgrow bzw. vgrow
Es kann ein Kindelement bestimmt werden, das von einem horizontalen bzw. vertikalen
Größenzuwachs des Containers profitieren soll, z. B.:
HBox.setHgrow(tfName, 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.:
double dist = 10.0;
Insets insets = new Insets(dist,dist,dist,dist);
HBox.setMargin(tfName, insets);
Zur späteren Verwendung als eingeschachteltes Element in einem BorderPane-Layout (siehe Ab-
schnitt 13.7.4) erstellen wir im Beispielprogramm zur Anwesenheitskontrolle ein HBox-Objekt als
separaten Layoutmanager für die drei Steuerelemente in der oberen „Zeile“:

Im folgenden Quellcode-Segment
double dist = 10.0;
double lblw = 70.0;
double btnw = 100.0;
Insets insets = new Insets(dist,dist,dist,dist);

HBox top = new HBox();


top.setAlignment(Pos.BASELINE_LEFT);
HBox.setMargin(lblName, insets);
lblName.setPrefWidth(lblw);
HBox.setMargin(btnAdd, insets);
btnAdd.setPrefWidth(btnw);
HBox.setMargin(tfName, insets);
HBox.setHgrow(tfName, Priority.ALWAYS);
top.getChildren().addAll(lblName, tfName, btnAdd);
wird ein HBox-Objekt erzeugt
HBox top = new HBox();
670 Kapitel 13 GUI-Programmierung mit JavaFX

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:

Ein BorderPane-Layoutmanager unterstützt die folgenden Anzeigeeinstellungen (layout constrai-


nts, siehe Abschnitt 13.7.1.3) für Kindelemente, die über statische Methoden verwaltet werden:
Abschnitt 13.7 Layoutmanager 671

• 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);

HBox top = new HBox();


. . .
top.getChildren().addAll(lblName, tfName, btnAdd);

BorderPane root = new BorderPane();


BorderPane.setMargin(lvPersons, insets);
BorderPane.setMargin(lblPresent, insets);
BorderPane.setMargin(btnRemove, insets);
lblPresent.setPrefWidth(lblw);
btnRemove.setPrefWidth(btnw);
BorderPane.setAlignment(lblPresent, Pos.CENTER_LEFT);
BorderPane.setAlignment(btnRemove, Pos.CENTER_RIGHT);

root.setTop(top);
root.setLeft(lblPresent);
root.setCenter(lvPersons);
root.setRight(btnRemove);

Es wird ein BorderPane-Objekt erzeugt


BorderPane root = new BorderPane();
und befüllt:
root.setTop(top);
root.setRight(btnRemove);
root.setLeft(lblPresent);
root.setCenter(lvPersons);
Das Label-Objekt lblPresent und das Button-Objekt btnRemove erhalten per setPrefWidth()
eine Wunschbreite, um für eine Ausrichtung mit anderen Steuerelementen zu sorgen. So gelingt ein
akzeptables Layout, wobei jedoch im Vergleich zur Verwendung des GridPane-Layoutmanagers
(siehe Abschnitt 13.7.1) etwas mehr Aufwand erforderlich ist:
672 Kapitel 13 GUI-Programmierung mit JavaFX

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;

FlowPane root = new FlowPane();


root.setPadding(new Insets(dist, dist, dist, dist));
root.setHgap(dist); root.setVgap(dist);

Button[] buttons = new Button[nob];


for (int i = 0; i < nob; i++) {
buttons[i] = new Button(String.valueOf(i + 1));
buttons[i].setPrefWidth(csize); buttons[i].setPrefHeight(csize);
buttons[i].setAlignment(Pos.CENTER);
}
root.getChildren().addAll(buttons);

stage.setScene(new Scene(root, 250, 250));


stage.setTitle("FlowPane");
stage.show();
}

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;

StackPane root = new StackPane();

Button[] buttons = new Button[nob];


for (int i = 0; i < nob; i++) {
buttons[i] = new Button();
buttons[i].setPrefSize(csize*(nob-i), csize*(nob-i));
}
root.getChildren().addAll(buttons);

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

13.8 Elementare Steuerelemente


Ein Steuerelement kann ...
• sich auf den Bildschirm zeichnen,
• auf elementare Maus- und Tastaturereignisse passend reagieren,
• über aufbereitete Ereignisse (z.B. aus der Klasse ActionEvent) kommunizieren.
Wir müssen praktisch nie eigene Steuerelemente entwickeln, weil JavaFX eine stattliche Sammlung
enthält. Im Manuskript werden einige besonders häufig benötigte Typen näher beschrieben. Deren
Einordnung in die Java-Klassenhierarchie zeigt die folgende Abbildung:
java.lang.Object

javafc.scene.Node

javafc.scene.Parent

javafc.scene.layout.Region

javafc.scene.control.Control

TextInputControl Labeled ListView

TextArea TextField Label ButtonBase

PasswordField CheckBox Button ToggleButton

RadioButton

Von den drei Optionen zur Konfiguration der Steuerelemente


• Java-Anweisungen
• Direkte FXML-Deklaration
• Indirekte FXML-Deklaration via Scene Builder
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.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)

Mit der Methode


public final void setGraphicTextGap(double value)
lässt sich der Abstand zwischen Text und Grafik verändern (Voreinstellung: 4), z. B.:
676 Kapitel 13 GUI-Programmierung mit JavaFX

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

13.8.3 Einzeiliges Texteingabefeld


Um in einem JavaFX-Fenster eine einzelne Zeile mit Text zu erfassen, verwendet man die Klasse
TextField , z. B.:

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

Eine TextInputControl-Komponente besitzt ab Fabrik einige Interaktionskompetenzen:


• Unterstützung der Zwischenablage
• Undo-Funktion (unter Windows z. B. über die Tastenkombination Strg-Z)
• Ein Kontextmenü, z. B.:
Abschnitt 13.8 Elementare Steuerelemente 679

Das Beispielprogramm verwendet ein VBox-Objekt als Wurzelknoten:


package de.uni_trier.zimk.jfxcontrols;

import javafx.application.Application;
. . .
import javafx.stage.Stage;

public class TextFieldDemo extends Application {


@Override
public void start(Stage stage) {
double dist = 10.0;
VBox root = new VBox(dist);
root.setPadding(new Insets(dist, dist, dist, dist));

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();
}

public static void main(String[] args) {


launch(args);
}
}

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:

Das IntelliJ-Projekt zum Programm finden Sie im Ordner:


…\BspUeb\JavaFX\Steuerelemente\Umschalter

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));
}

13.9 Modulare JavaFX-Anwendung ausliefern


In diesem Abschnitt beschäftigen wir uns mit der Auslieferung eines JavaFX-Programms an Kun-
den mit einem Windows-Rechner. Auf diesem Rechner soll die aktuelle LTS-Version 17 von Open-
JDK vorausgesetzt werden. Bzgl. JavaFX werden zwei Varianten vorgestellt:
• Verwendung einer OpenJFX 17 - Installation auf dem Kundenrechner (siehe Abschnitt 2.5).
• Auslieferung des Programms inklusive der erforderlichen JavaFX-Dateien
Als Beispiel verwenden wir die im Abschnitt 13.4 beschriebene Version des Attendance-
Programms. Nach dem Menübefehl
File > Project Structure > Artifacts
fordern wir die Erstellung einer modularen jar-Datei mit dem Programm an:

Nach einem Klick auf das Ordnersymbol wählen wir Attendance als Hauptklasse:
684 Kapitel 13 GUI-Programmierung mit JavaFX

Die jar-Dateien zu JavaFX sollen in den Ausgabeordner.


Schließlich kann über das Kontrollkästchen Include in project build dafür gesorgt werden, dass
der Ordner mit den auslieferungsbereiten Dateien bei jeder Projekterstellung entsteht:

Wird das Projekt anschließend z. B. über dem Symbol erstellt, dann erhält man im Ausgabeord-
ner

u. a. die modulare Datei AttendanceMVC.jar die lediglich 5 KB belegt.


Abschnitt 13.10 Übungsaufgaben zum Kapitel 13 685

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

13.10 Übungsaufgaben zum Kapitel 13


1) Welche der folgenden Aussagen sind richtig bzw. falsch?
1. Im Szenegraphen eines JavaFX-Fensters ist genau ein Layoutmanager vorhanden.
2. In einem GridPane-Layoutmanager kann ein Steuerelement auch mehr als eine Zelle bele-
gen.
3. In der Application-Ableitung zu einer JavaFX-Anwendung ist die main() - Methode unver-
zichtbar.
4. Jede JavaFX-Anwendung benötigt mindestens eine FXML-Datei.
5. Die Property-Objekte von JavaFX halten sich an das erprobte Entwurfsmuster von JavaBe-
ans-Eigenschaften und unterstützen außerdem leistungsfähige Techniken zur Änderungssig-
nalisierung und zur Datenbindung.

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.

3) Erstellen Sie einen DM-Euro - Konverter:

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

• Um das Label-Objekt mit dem Konvertierungsergebnis zu bewegen, den verfügbaren Platz


in horizontaler Richtung komplett zu belegen, wird es mit einer AnchorPane-Verpackung
in seine GridPane-Zelle gesteckt. Im AnchorPane-Container wird das Label-Objekt links
und rechts verankert:
AnchorPane anchorPane = new AnchorPane();
AnchorPane.setLeftAnchor(lblErgebnis, 0.0);
AnchorPane.setRightAnchor(lblErgebnis, 0.0);
anchorPane.getChildren().add(lblErgebnis);
root.add(anchorPane, 1, 1);
• Bei dem horizontalen Pfeil in den Beschriftungen der Optionsschalter handelt es sich um das
Unicode-Zeichen mit der Nummer 0x27A0. Über eine Unicode-Escape-Sequenz (siehe Ab-
schnitt 3.3.11.4) lassen sich beliebige Unicode-Zeichen in einem Java-Programm verwen-
den, z. B.:
dm2euro = new RadioButton("DM "+'\u27a0'+" Euro");
14 Ein- und Ausgabe über Datenströme
Bisher haben wir Daten nur in den zu einer Methode, zu einem Objekt oder zu einer Klasse gehöri-
gen Variablen gespeichert. Der lesende und schreibende Zugriff auf Variablen im Hauptspeicher
bequem und schnell zu realisieren, doch spätestens beim Verlassen des Programms gehen alle Vari-
ableninhalte verloren. In diesem Kapitel behandeln wir elementare Verfahren zum sequentiellen
Datenaustausch zwischen den Variablen eines Java-Programms und externen Datenquellen bzw. -
senken. Es werden Verfahren beschrieben, um Zeichenfolgen, primitive Werte (Typ byte, int, dou-
ble etc.) oder ganze Objekte in eine Datei auf einem Permanentspeicher (z. B. Festplatte oder SSD)
zu schreiben bzw. von dort zu lesen. Außerdem werden wir uns mit der Verwaltung von Dateien
und Verzeichnissen beschäftigen.
Ausblick auf zwei verwandte Themen:1
• Bei der Beschäftigung mit Netzwerkverbindungen werden Sie weitere, außerordentlich
wichtige Datenquellen bzw. -senken kennenlernen und dabei von Ihren Kenntnissen über die
sequentielle Datenstromtechnik profitieren (siehe Kapitel 16).
• Bei der Suche nach einer professionellen Persistenzlösung spielt die Datenbankprogram-
mierung eine wichtige Rolle. Ihre leistungsfähigen Datenverwaltungstechniken bewähren
sich im Netzwerk- und im Mehrbenutzerkontext. Dabei überlässt man den direkten Kontakt
mit Dateien einer speziellen Software, dem Datenbankmanagement-System (DBMS).
Mit dem Vorsatz, komplexe Dateiverwaltungsaufgaben einem DBMS zu überlassen, verzichten wir
in diesem Manuskript auf die Behandlung des wahlfreien Zugriffs auf Dateiinhalte (siehe z. B.
Krüger & Hansen 2014, Kapitel 21) und beschränken uns auf Techniken, die externe Datenquellen
bzw. -senken unidirektional im Vorwärtsgang bearbeiten.
Wir beschränken uns außerdem auf die am Datenstrommodell (siehe Abschnitt 14.1.1) orientierte
Ein-/Ausgabetechnik. Die als Ergänzung zur Datenstromtechnik für Anwendungen mit intensivem
Datentransfer konzipierte Channel-Technik kommt nicht explizit zum Einsatz, wird allerdings von
etlichen API-Klassen im Hintergrund verwendet.2
Unsere Beispielprogramme verwenden regelmäßig Klassen aus den Paketen java.io und
java.nio.file, sodass sich der Import dieser Pakete meistens lohnt:
import java.io.*;
import java.nio.file.*;

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

try (OutputStream fos = Files.newOutputStream(file)) {


fos.write(arro);
}

try (InputStream fis = Files.newInputStream(file)) {


fis.read(arri);
}

for (int i : arri)


System.out.println(i);
}
}
Abschnitt 14.1 Grundlagen 691

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.

14.1.3 Klassifikation der Stromverarbeitungsklassen


Das Paket java.io enthält vier abstrakte Klassen, von denen diverse Stromverarbeitungsklassen ab-
stammen:
• InputStream und OutputStream
Die Klassen aus diesen Hierarchien lesen bzw. schreiben Ströme aus Bytes. Sie können be-
liebige Daten transportieren, sollten aber zum Transport von Zeichenfolgen nicht mehr ver-
wendet werden.
• Reader und Writer
Die Klassen aus diesen Hierarchien lesen bzw. schreiben Ströme aus Zeichen in einer be-
stimmten Codierung (z. B. UTF-8 oder ANSI). Wird ein Zeichenstrom in eine Datei geleitet,
kann diese anschließend mit jedem Texteditor bearbeitet werden, der die verwendete Codie-
rung versteht.
Die byte-orientierten Klassen gehörten schon zur ersten Java-Generation und sollten ursprünglich
auch zur Verarbeitung von Texten dienen. Zur Lösung von Problemen bei der Internationalisierung
von Java-Programmen wurden mit Java 1.1 die zeichenorientierten Klassen hinzugefügt. Wo alte
und neue Lösungen zur Verarbeitung von Textdaten konkurrieren, sollten die zeichenorientierten
Klassen den Vorzug erhalten.
Bei den Abkömmlingen der vier abstrakten Klassen sind nach der Funktion zu unterscheiden:
• Ein- bzw. Ausgabeklassen
Sie haben direkten Kontakt zu einer Datenquelle bzw. -senke. Sollen z. B. Bytes aus ei-
ner Datei gelesen bzw. in eine Datei geschrieben werden, kommen die Klassen FileInput-
Stream bzw. FileOutputStream zum Einsatz.
692 Kapitel 14 Ein- und Ausgabe über Datenströme

• Eingabe- bzw. Ausgabetransformationsklassen


Sie dienen zum Transformieren von Strömen und werden oft auch als Filterklassen be-
zeichnet. Sollen z. B. Werte mit beliebigem primitivem Datentyp (int, double, etc.) aus ei-
ner Datei gelesen werden, dann schaltet man einen Filterstrom und einen Eingabestrom hin-
tereinander:
o Ein Objekt der Eingabestromklasse FileInputStream ist mit der Datei verbunden und
besorgt von dort Byte-Sequenzen.
o Ein Objekt der Filterstromklasse DataInputStream setzt Byte-Sequenzen zu den ange-
forderten primitiven Werten zusammen.
o Wir richten unsere Anforderungen an den Filterstrom.
Den Hierarchien zu den vier abstrakten Basisklassen werden später eigene Abschnitte gewidmet.
Vorab werfen wir schon einmal einen Blick auf die InputStream-Hierarchie:
FileInputStream

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.

14.1.4 Aufbau und Verwendung der Transformationsklassen


Eine Transformations- bzw. Filterklasse baut auf einer anderen Datenstromklasse auf und stellt Me-
thoden für eine erweiterte Funktionalität zur Verfügung. Wie diese Zusammenarbeit organisiert
wird, betrachten wir am Beispiel der Eingabetransformationsklasse DataInputStream aus der In-
putStream-Hierarchie. Diese Klasse besitzt …
• eine Instanzvariable vom Typ InputStream,
• in ihrem Konstruktor einen Parameter vom Typ InputStream, dessen Wert der
InputStream-Instanzvariablen zugewiesen wird.
Folglich muss beim Erstellen eines DataInputStream-Objekts die Referenz auf ein Objekt aus ei-
ner von InputStream abstammenden Klasse übergeben werden. Im folgenden Beispiel wird das
InputStream-Objekt von der statischen Files-Methode newInputStream() geliefert, die als Para-
meter ein Path-Objekt erhält, das eine Datei repräsentiert (zu den Klassen Files und Path siehe
Abschnitt 14.2.1):
DataInputStream dis = new DataInputStream(Files.newInputStream(file))
Die Transformationsleistung eines DataInputStream-Objekts besteht darin, Werte primitiver Da-
tentypen aus einer byte-Sequenz passender Länge zusammenzusetzen. Der Filterstrom nimmt also
Bytes entgegen und liefert z. B. int-Werte (zusammengesetzt aus jeweils 4 Bytes) ab. Aus dem
elementaren Byte-Strom wird ein Strom, dem Daten von primitivem Typ entnommen werden kön-
nen:
Abschnitt 14.1 Grundlagen 693

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};

try (DataOutputStream dos = new DataOutputStream(Files.newOutputStream(file))) {


for(int el : iar)
dos.writeInt(el);
}

try (DataInputStream dis = new DataInputStream(Files.newInputStream(file))) {


for(int i = 0; i < iar.length; i++)
System.out.println(iar[i] = dis.readInt());
}
}
}

Am Beispiel DataInputStream sollen noch einmal wichtige Merkmale einer Transformations-


bzw. Filterklasse zusammengefasst werden:
• Die Filterklasse DataInputStream besitzt eine im Konstruktor initialisierte Instanzvariable
vom Typ InputStream, über die der Kontakt zu einem Eingabestromobjekt hergestellt wird.
• Die DataInputStream-Eingabemethoden beauftragen den eingebundenen InputStream,
Bytes in hinreichender Zahl zu beschaffen. Diese werden dann zu Werten eines primitiven
Datentyps zusammengesetzt.
• DataInputStream-Objekte können mit jedem InputStream-Objekt kooperieren. Beim Le-
sen aus einer Datei verwendet man die Eingabeklasse FileInputStream aus der Paket
java.io.
• Ein Aufruf der DataInputStream-Methode close() wird an das verbundene InputStream-
Objekt durchgereicht.
694 Kapitel 14 Ein- und Ausgabe über Datenströme

14.1.5 Zum guten Schluss


Ist ein Datenstromobjekt mit einer externen Quelle oder Senke verbunden, dann ist eine Ressource
(z. B. Datei oder Netzwerkverbindung) belegt, die für andere Prozesse nicht mehr (uneinge-
schränkt) zur Verfügung steht. Nach der Programmbeendigung sind die belegten Ressourcen zwar
auf jeden Fall wieder frei, doch sollte man die Benutzer oder andere Prozesse nicht ohne Grund so
lange warten lassen. Zudem können geöffnete Dateien auch programminterne Arbeiten blockieren
(z. B. das Umbenennen).
Viele Datenstromklassen aus der OutputStream- oder Writer-Hierarchie setzen Zwischenspei-
cher ein, die unbedingt vor dem Entfernen der Datenstromobjekte geleert werden müssen, z. B.
durch einen close() - Aufruf. Anderenfalls gehen die gepufferten Daten verloren.
Alle Datenstromobjekte aus dem Java-API beherrschen die Methode close(), die ggf. Zwischen-
speicher entleert, den Strom schließt und die assoziierten Ressourcen freigibt. Danach ist das Stro-
mobjekt zum Lesen bzw. Schreiben von Daten nicht mehr zu gebrauchen.
Manche API-Klassen überschreiben die von Object geerbte und vom Garbage Collector ausgeführ-
te Methode finalize() und rufen dort die Methode close() auf, z. B. FileOutputStream bis zur Ver-
sion Java 8:
protected void finalize() throws IOException {
if (fd != null) {
if (fd == FileDescriptor.out || fd == FileDescriptor.err) {
flush();
} else {
close();
}
}
}
Es gibt jedoch zwingende Gründe für den expliziten close() - Aufruf:
• Es nicht keinesfalls sicher, ob die finalize() - Methode tatsächlich aufgerufen wird, weil der
Garbage Collector nur bei Hauptspeicherbedarf zum Einsatz kommt. Erst recht sind Zeit-
punkt und Reihenfolge der Aufrufe für verschiedene Objekte ungewiss. Im Java-Tutorial
(Oracle 2021), das für Java 8 geschrieben wurde, heißt es dazu unmissverständlich:1
The finalize() method may be called automatically by the system, but when it is called,
or even if it is called, is uncertain. Therefore, you should not rely on this method to do
your cleanup for you.
• Viele puffernde Ausgabeklassen (z. B. BufferedOutputStream, OutputStreamWriter)
überschreiben die von java.lang.Object geerbte finalize() - Methode nicht. Weil das Erb-
stück einen leeren Anweisungsblock besitzt, wird close() dort nicht aufgerufen (siehe z. B.
Abschnitt 14.3.1.5).
Um allen Problemen aus dem Weg zu gehen, sorgt man dafür, dass jeder Strom so früh wie möglich
durch einen close() - Aufruf geschlossen wird. Bei manchen mit programminternen Quellen oder
Senken verbundenen Datenströmen (z. B. ByteArrayOutputStream) ist die close() - Methode
überflüssig und wirkungslos, aber nicht schädlich.

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;
}

public static void main(String args[]) throws IOException {


Path file = Paths.get("werte.dat");
System.out.println(mean(file));
}
}
Die Vorteile der try-with-resources - Lösung sind:

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

• Die Ressourcen werden automatisch in der richtigen Reihenfolge geschlossen.


• Falls es in der Methode mean() zu einer IOException kommt, sorgt der Compiler dafür,
dass die ursprüngliche Ausnahme an den Aufrufer weitergeleitet wird und nicht eine eventu-
ell beim Schließen der Ressourcen aufgetretene Ausnahme, was beim manuellen Schließen
per finally-Klausel passieren kann (vgl. Abschnitt 11.8).1
• Der Sichtbarkeitsbereich der Ressourcen ist auf die try-Anweisung beschränkt, sodass der
Gefahrenbereich für (fehlerhafte) Zugriffe möglichst klein gehalten wird.

14.2 Verwaltung von Dateien und Verzeichnissen


Bis Java 6 (alias 1.6) war für den Umgang mit Dateien und Verzeichnissen (z. B. Erstellen, auf
Existenz prüfen, Löschen, Attribute lesen und setzen) die Klasse File aus dem Paket java.io zustän-
dig. Seit Java 7 (alias 1.7) bieten Klassen im Paket java.nio.file (z. B. Path, Paths und Files) bes-
sere Lösungen. Weil die Möglichkeiten des schon seit Java 1.4 vorhandenen Pakets java.nio in der
Version 7 stark verbessert wurden, spricht man vom NIO.2 - API.
Bei neuen Projekten, die nicht auf Kompatibilität mit Java 6 angewiesen sind, sollten statt der Klas-
se File die moderneren Typen aus dem aus dem Paket java.nio.file (z. B. Path, Paths und Files)
verwendet werden (siehe Abschnitt 14.2.1). Einige Nachteile der veralteten Methoden sind:
• Kommunikation von Fehlern per Rückgabewert statt über Ausnahmen (vgl. Kapitel 11)
Wenn etwa das Löschen einer Datei über die File-Instanzmethode delete() misslingt, dann
erhält der Aufrufer den Rückgabewert false. Er erfährt jedoch nichts über die Ursache des
Problems (z. B. Datei nicht vorhanden, fehlende Rechte). Demgegenüber kommuniziert die
statische Files-Methode delete() über Ausnahmeobjekte und kann daher den Aufrufer über
einen Fehler detailliert informierten.
• Keine Unterstützung für symbolische Links
Dateisystemeinträge, die auf eine Datei oder ein Verzeichnis verweisen, werden von
UNIX/Linux seit jeher und von Windows seit der Version Vista (bzw. Server 2008) für das
NTFS-Dateisystem unterstützt.2
Eine Verwendung der mit Java 6 kompatiblen Technik kommt bei älteren, noch weiter zu pflegen-
den Projekten in Frage. Außerdem ist die Klasse File z. B. als Parameterdatentyp bei vielen Daten-
stromklassen weiter im Spiel. Daher wird im Manuskript auch die ältere Technik beschrieben (siehe
Abschnitt 14.2.2).

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

14.2.1 Dateisystemzugriffe über das NIO.2 - API


Wir beschränken uns auf die Typen Path, Paths und Files aus dem Paket java.nio.file.

14.2.1.1 Repräsentation von Dateisystemeinträgen


Der Typ Path im Paket java.nio.file repräsentiert einen Eintrag im hierarchischen Dateisystem ei-
nes Rechners. Ein absoluter Pfad ...
• beginnt mit dem Wurzelknoten (z. B. / bei Linux oder C: bei Windows),
• enthält optional eine Serie von Zwischenknoten,
• und endet mit dem Zielknoten (Datei, Verzeichnis oder symbolischer Link).
Fehlt der Wurzelknoten, dann ist der Pfad relativ und nur in einem bestimmten Kontext eine kor-
rekte Ortsangabe.
Path wurde als Schnittstelle realisiert, sodass es keinen Konstruktor zu diesem Typ gibt. In diese
Presche springt die Klasse Paths mit der statischen Methode get(), von der zwei Überladungen vor-
handen sind:
public static Path get(URI uri)
public static Path get(String first, String... more)
In der Aktualparameterliste der zweiten Überladung darf dem obligatorischen ersten Knotennamen
eine beliebig lange Liste weiterer Knotennamen folgen (zum Serienparameter siehe Abschnitt
4.3.1.4.3). Im resultierenden Objekt (aus einer das Interface Path implementierenden Klasse) landet
also eine Serie von Knotennamen. Bei Verwendung dieser Syntax taucht das plattformabhängige
Knotentrennzeichen nicht auf, z. B. im folgenden absoluten Pfad:
Path p0 = Paths.get("C:","Users","otto","Documents","java","io","ausgabe.txt");

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

• public int getNameCount()


Liefert die Anzahl der Namenssegmente (ohne Wurzelknoten), z. B.:
Quellcodesegment Ergebnis
p1.getNameCount() 4

• public Path getRoot()


Liefert das Path-Objekt zum Wurzelknoten, z. B.:
Quellcodesegment toString() - Ergebnis
p1.getRoot() U:\

• public Path getName(int index)


Liefert das relative Path-Objekt zu einem Knoten über einen nullbasierten Index, wobei die
Nummer 0 zum Nachbarn des Wurzelknotens gehört, z. B.:
Quellcodesegment toString() - Ergebnis
p1.getName(1) Java

• public Path subpath(int startIndex, int endIndex)


Liefert eine Teilstrecke des Pfades (inklusive Startindex, exklusive Endindex), z. B.:
Quellcodesegment toString() - Ergebnis
p1.subpath(0, 2) Eigene Dateien\Java

• public boolean isAbsolute()


Informiert darüber, ob ein absoluter (mit einem Wurzelknoten startender) Pfad vorliegt,
z. B.:
Quellcodesegment toString() - Ergebnis
p1.isAbsolute() true
p1.getName(1).isAbsolute() false

• public Path resolve(Path other)


public Path resolve(String other)
Eine nützliche Anwendung der Methode resolve() ergibt sich dann, wenn ....
o das angesprochene Path-Objekt einen Wurzelknoten enthält und auf einen Ordner
zeigt,
o und das resolve() - Parameterobjekt einen Dateinamen enthält.
Dann resultiert ein absoluter Pfad, der auf die Datei zeigt, z. B.:
Quellcodesegment toString() - Ergebnis
Path dir = Paths.get("U:/Eigene Dateien");
Path datei = dir.resolve("Ausgabe.txt"); U:\Eigene Dateien\Ausgabe.txt

• public Path resolveSibling(Path other)


public Path resolveSibling(String other)
Im Unterschied zu resolve() bezieht sich bei resolveSibling() die Auflösung auf das über-
geordnete Path-Objekt. Eine nützliche Anwendung ergibt sich z. B. dann, wenn ...
o das angesprochene Path-Objekt einen Wurzelknoten enthält und auf eine Datei zeigt
o und das Parameterobjekt einen Dateinamen enthält.
Dann resultiert ein absoluter Pfad mit dem Ordner aus dem angesprochenen Path-Objekt
und dem Dateinamen aus dem Parameterobjekt, z. B.:
Quellcodesegment toString() - Ergebnis
Path d1=Paths.get("U:/Eigene Dateien/a.txt");
Path d2=d1.resolveSibling("b.txt"); U:\Eigene Dateien\b.txt
Abschnitt 14.2 Verwaltung von Dateien und Verzeichnissen 699

• public File toFile()


Die Path-Methode toFile() liefert das dem Path-Objekt entsprechende File-Objekt (vgl.
Abschnitt 14.2.2), z. B.:
Quellcodesegment toString() - Ergebnis
File f = p1.toFile(); U:\Eigene Dateien\Java\io\ausgabe.txt

• public URI toUri()


Von der Methode toUri() erhält man ein URI-Objekt (Uniform Resource Identifier) zum
angesprochenen Path-Objekt, das sich z. B. zum Öffnen einer Datei durch einen WWW-
Browser verwenden lässt, z. B.:
Quellcodesegment toString() - Ergebnis
p1.toUri() file:///U:/Eigene%20Dateien/Java/io/ausgabe.txt

• public int compareTo()


Mit compareTo() befragt, äußert sich ein Path-Objekt zu seiner Sortierungspriorität in Be-
zug auf einen Vergleichspfad. Insbesondere wird mit der Rückgabe 0 die Identität gemeldet,
wobei in Abhängigkeit von der Zielplattform (z. B. unter Windows) die Groß-
/Kleinschreibung für das Vergleichsergebnis irrelevant ist, z. B.:
Quellcodesegment Ausgabe
Path p1 = Paths.get("U:/Eigene Dateien/Java/io/Ausgabe.txt");
Path p2 = Paths.get("U:/eigene dateien/java/io/ausgabe.txt");
System.out.println(p2.compareTo(p1)); 0

• public Path normalize()


Damit redundante Bestandteile in der Namenssequenz eines Path-Objekts einen Vergleich
nicht stören, sollte man diese per normalize() - Methode entfernen, z. B.:
Quellcodesegment Ausgabe
Path p1 = Paths.get("U:/Eigene Dateien/Java/io/ausgabe.txt");
Path p2 = Paths.get("U:/eigene dateien/java/../java/io/ausgabe.txt");
System.out.println(p2.compareTo(p1)); -27
System.out.println(p2.normalize().compareTo(p1)); 0

Weitere Path-Methoden werden anschließend im Zusammenhang mit ihrer typischen Verwendung


beschrieben.

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

14.2.1.3 Verzeichnis anlegen


Um das Verzeichnis
U:\Eigene Dateien\Java\io\
anzulegen, erzeugen wir zunächst ein passendes Path-Objekt (vgl. Abschnitt 14.2.1.1):
Path dir = Paths.get("U:", "Eigene Dateien", "Java", "io");
Mit der statischen Files-Methode createDirectory(), die ein Path-Objekt als Parameter erwartet,
lässt sich ein neues Verzeichnis in einem bereits vorhandenen Ordner anlegen. Sollen nötigenfalls
auch erforderliche Zwischenstufen automatisch angelegt werden, ist die Methode
createDirectories() zu verwenden, z. B.:
try {
Files.createDirectories(dir);
} catch (FileAlreadyExistsException ae) {
System.err.println(dir + " existiert, ist aber kein Verzeichnis.");
System.exit(1);
}
Von beiden Methoden sind die folgenden geprüften Ausnahmen zu erwarten (catch-Block bzw.
Deklaration erforderlich):
• eine allgemeine IOException
• die IOException-Spezialisierung FileAlreadyExistsException
Diese Ausnahme tritt auf, wenn bereits ein Dateisystemeintrag mit dem gewünschten Na-
men existiert, der aber kein Verzeichnis ist. Die Aufforderung, ein bereits vorhandenes Ver-
zeichnis anzulegen, hat keine Ausnahme zur Folge.
Der Exception Handler im obigen Code-Segment schreibt eine Fehlermeldung in den sogenannten
Fehlerausgabestrom. Dieser vom Laufzeitsystem verwaltete und per Voreinstellung zur Konsole
kanalisierte Standardstrom ist in Java über die statische Referenzvariable err der Klasse System
anzusprechen. System.err zeigt wie der Standardausgabestrom System.out auf ein Objekt der
Klasse PrintStream (siehe Abschnitt 14.3.1.6). Beide Standardströme werden automatisch zur Ver-
fügung gestellt und müssen weder geöffnet noch geschlossen werden.

14.2.1.4 Datei explizit erstellen


Zwar wird z. B. beim Erzeugen eines FileOutputStream-Objekts eine benötigte Datei bei Bedarf
automatisch erstellt, doch ergeben sich auch Anlässe, eine Datei (mit einem Inhalt von 0 Bytes)
explizit anzulegen, wozu die statische Files-Methode createFile() bereitsteht. Das als Parameter
benötigte Path-Objekt zur Datei kann man z. B. aus einem vorhanden Path-Objekt zum Verzeich-
nis und einem Dateinamen über die Path-Methode resolve() gewinnen:
Path file = dir.resolve("Ausgabe.dat");
try {
Files.createFile(file);
} catch (FileAlreadyExistsException ae) {
System.err.println(file + " existiert bereits.");
}

14.2.1.5 Attribute von Dateisystemobjekten ermitteln


Mit diversen statischen Methoden der Serviceklasse Files, die allesamt eine Ausnahme vom Typ
IOException werfen können, lassen sich einzelne Attribute von Dateisystemobjekten ermitteln.
z. B.:
Abschnitt 14.2 Verwaltung von Dateien und Verzeichnissen 701

• public static FileTime getLastModifiedTime(Path path, LinkOption... options)


throws IOException
Für das durch den Pfad bezeichnete Dateisystemobjekt erfährt man über die Rückgabe vom
Typ FileTime den Zeitpunkt der letzten Änderung. Über die Methode toString() befragt,
liefert das FileTime-Objekt eine Zeitangabe in GMT nach der Norm ISO 8601, z. B.:
2022-02-06T12:16:06.010008Z
Zum Parameter LinkOption folgt eine Erläuterung hinter der Auflistung.
• public static long size(Path path) throws IOException
Bei einer regulären Datei erhält man die Größe in Bytes. Bei anderen Dateisystemobjekten
(Verzeichnis oder symbolischer Link) ist die Rückgabe implementationsabhängig.
• public static boolean isRegularFile(Path path, LinkOption... options)
public static boolean isDirectory(Path path, LinkOption... options)
public static boolean isSymbolicLink(Path path)
Diese Methoden informieren darüber, ob das durch den Pfad bezeichnete Dateisystemobjekt
eine reguläre Datei, ein Verzeichnis oder ein symbolischer Link ist.
Mit dem bei einigen Methoden vorhandenen Serienparameter (vgl. Abschnitt 4.3.1.4.3) vom Enu-
merationstyp LinkOption legt man fest, wie symbolische Links, die auf eine Datei oder ein Ver-
zeichnis verweisen, behandelt werden sollen. Per Voreinstellung wird ein Link aufgelöst, sodass die
ermittelten Attributausprägungen vom Verweisziel stammen. Mit dem Parameterwert
LinkOption.NOFOLLOW_LINKS unterbleibt die Auflösung, sodass die Attributausprägungen
vom Link stammen.
In den folgenden Anweisungen werden Informationen über eine Datei gesammelt:
Path dir = Paths.get("U:", "Eigene Dateien", "Java", "io");
Path file = dir.resolve("Ausgabe.dat");
. . .
System.out.println("Eigenschaften von " + file);
System.out.println(" Größe in Bytes: " + Files.size(file));
System.out.println(" Letzte Änderung: " + Files.getLastModifiedTime(file));
System.out.println(" Datei: " + Files.isRegularFile(file));
System.out.println(" Verzeichnis: " + Files.isDirectory(file));
System.out.println(" Symb. Link: " + Files.isSymbolicLink(file));
Ausgabe:
Eigenschaften von U:\Eigene Dateien\Java\io\Ausgabe.dat
Größe in Bytes: 3
Letzte Änderung: 2020-02-08T18:21:29.17708Z
Datei: true
Verzeichnis: false
Symb. Link: false

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

Die Schnittstelle BasicFileAttributes wird erweitert von:


• DosFileAttributes
• PosixFileAttributes

14.2.1.6 Zugriffsrechte für Dateien ermitteln


Mit den statischen Methoden isReadable(), isWritable() und isExecutable() der Klasse Files kann
man feststellen, ob das aktive Programm (bzw. die JVM) eine Datei lesen, schreiben oder ausführen
darf:
• public static boolean isReadable(Path path)
Bedeutung der Rückgabewerte:
o true
Die Datei existiert, und es bestehen Leserechte.
o false
Entweder ist die Datei nicht vorhanden, oder es bestehen keine Leserechte, oder die
Leserechte sind nicht feststellbar.
• public static boolean isWritable(Path path)
Bedeutung der Rückgabewerte:
o true
Die Datei existiert, und es bestehen Schreibrechte.
o false
Entweder ist die Datei nicht vorhanden, oder es bestehen keine Schreibrechte, oder
die Schreibrechte sind nicht feststellbar.
• public static boolean isExecutable(Path path)
Bedeutung der Rückgabewerte:
o true
Die Datei existiert und kann vom aktiven Programm ausgeführt werden.
o false
Entweder ist die Datei nicht vorhanden, oder es bestehen keine Ausführungsrechte,
oder die Ausführungsrechte sind nicht feststellbar.
Beispiel:
Quellcodesegment Ausgabe
System.out.println(Files.isWritable(file)); true

Streng genommen kann man sich schon nach kurzer Zeit nicht mehr darauf verlassen, dass die er-
mittelten Zugriffsrechte noch bestehen.

14.2.1.7 Attribute ändern


Über die Klasse Files lassen sich einige Attribute von Dateisystemobjekten ändern. Wir beschrän-
ken uns auf das Datum der letzten Modifikation:
public static Path setLastModifiedTime(Path path, FileTime... time)
throws IOException
Zum Erstellen des benötigten FileTime-Objekts ist die statische Methode fromMillis() der Klasse
FileTime (im Paket java.nio.file.attribute) geeignet. Um deren Parameter über vertraute Zeitein-
heiten festlegen zu können, wird im folgenden Vorschlag ein Objekt der Klasse Calendar verwen-
det:
Abschnitt 14.2 Verwaltung von Dateien und Verzeichnissen 703

Calendar cal = Calendar.getInstance();


cal.set(1980, Calendar.APRIL, 1, 0, 0, 0);
Files.setLastModifiedTime(file, FileTime.fromMillis(cal.getTimeInMillis()));
So gelingt der Sprung zurück in die Zeit vor dem ersten IBM-PC:

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

14.2.1.8 Über Verzeichniseinträge iterieren


Zu einem Ordner liefert die statische Files-Methode newDirectoryStream() ein Objekt aus einer
Klasse, welche u. a. die Interfaces AutoClosable und Iterable<Path> beherrscht.2 Für das geöffne-
te Verzeichnis werden Ressourcen belegt, sodass ein möglichst frühes Schließen erforderlich ist,
was am besten in einer try-Anweisung mit automatischer Ressourcenfreigabe geschieht. Im folgen-
den Beispiel wird das DirectoryStream<Path> - Objekt in einer erweiterten for-Schleife (siehe
Abschnitt 3.7.3.2) dazu verwendet, um über die Einträge im Verzeichnis zu iterieren:
try (DirectoryStream<Path> ds = Files.newDirectoryStream(dir)) {
for (Path path: ds)
System.out.println(path.getFileName());
}

14.2.1.9 Datei und Ordner kopieren


Zum Kopieren von Dateien wurde vor Java 7 häufig ein Gespann aus einem FileInputStream und
einem FileOutputStream mit Zwischenspeicherung in einem byte-Array verwendet (siehe Beispiel
im Abschnitt 14.3.1.2). Mit der in drei Überladungen vorhandenen statischen Files-Methode copy()
lässt sich deutlich bequemer eine meist flottere Lösung realisieren, z. B.:
import java.io.IOException;
import java.nio.file.*;
class FilesCopy {
public static void main(String[] args) throws IOException{
Path quelle = Paths.get("quelle.dat");
Path ziel = Paths.get("ziel.dat");
Files.copy(quelle, ziel, StandardCopyOption.REPLACE_EXISTING);
}
}

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.

14.2.1.10 Umbenennen und Verschieben


Mit der statischen Files-Methode move() lässt sich ein Dateisystemobjekt umbenennen oder ver-
schieben:
public static Path move(Path source, Path target, CopyOption... options)
throws IOException
Statt die Methode komplett zu beschreiben (siehe Java-Tutorial, Oracle 2021), beschränken wir uns
auf zwei Beispiele.3
Soll eine Datei umbenannt werden, gibt man eine Zieldatei im selben Ordner an, wobei das benötig-
te Path-Objekt bequem über die Path-Methode resolveSibling() zu erstellen ist, z. B.:
Files.move(file, file.resolveSibling("Umbenannt.dat"));
Auf die Angabe von Optionen wird in diesem Beispiel verzichtet, was bei einem Serienparameter
erlaubt ist (vgl. Abschnitt 4.3.1.4.3).

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().

14.2.1.12 Informationen über Dateisysteme ermitteln


Mit Hilfe der Klassen FileSystem, FileSystems und FileStore kann man sich über die Dateisyste-
me des lokalen Rechners informieren. Ein Windows-Rechner verfügt in der Regel nur über ein Da-
706 Kapitel 14 Ein- und Ausgabe über Datenströme

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

14.2.1.13 Weitere Optionen


Aus Zeitgründen können einige attraktive Neuerungen im NIO.2 - API nur erwähnt werden (siehe
Kapitel Basic I/O im Java-Tutorial, Oracle 2021):2

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

• Rekursives Durchwandern eines Verzeichniszweigs


• Suche nach Dateinamen, die ein Muster erfüllen
• Überwachung eines Dateisystemordners auf Veränderungen (siehe z. B. Krüger & Hansen
2014, S. 461ff)

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};
. . .
}
}

14.2.2.1 Verzeichnis anlegen


Zunächst legen wir das Verzeichnis
U:\Eigene Dateien\Java\FileDemo\AusDir
an:
String ordner = "U:/Eigene Dateien/Java/FileDemo/AusDir";
File dir = new File(ordner);
if (dir.exists()) {
if (dir.isDirectory())
System.out.println("Das Verzeichnis " + ordner + " existiert bereits.");
else {
System.out.println(ordner + " existiert, ist aber kein Verzeichnis.");
System.exit(1);
}
} else
if (dir.mkdirs())
System.out.println("Verzeichnis " + ordner + " erstellt");
else {
System.out.println("Verzeichnis " + ordner + " konnte nicht erstellt werden.");
System.exit(1);
}
Im File-Konstruktor kann ein absoluter (z. B. U:/Eigene Dateien/Java/FileDemo/AusDir) oder ein
relativer, vom aktuellen Verzeichnis ausgehender, Pfad (z. B. AusDir) angegeben werden.
Weil der Rückwärtsschrägstrich in Java eine Escape-Sequenz einleitet, muss unter Windows zwi-
schen Pfadbestandteilen entweder der Vorwärtsschrägstrich (/) oder ein verdoppelter Rückwärts-
schrägstrich gesetzt werden (z. B.: U:\\Eigene Dateien\\Java\\FileDemo\\AusDir). In der Konstan-
ten File.pathSeparatorChar findet sich das für die aktuelle Plattform gültige Trennzeichen zwi-
schen Pfadbestandteilen.
Mit der File-Methode exists() lässt sich die Existenz eines Ordners oder einer Datei überprüfen. Ihr
boolescher Rückgabewert ist genau dann true, wenn die Suche erfolgreich war.
Ob es sich bei einem Verzeichniseintrag um ein Unterverzeichnis handelt, stellt man mit der Me-
thode isDirectory() fest.
708 Kapitel 14 Ein- und Ausgabe über Datenströme

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).

14.2.2.2 Dateien explizit erstellen


Zwar wird z. B. beim Erzeugen eines FileOutputStream-Objekts eine benötigte Datei bei Bedarf
automatisch erstellt, doch ergeben sich auch Anlässe, eine Datei explizit anzulegen, wozu die Me-
thode createNewFile() der Klasse File bereitsteht:
String name = ordner + "/Ausgabe.dat";
File f = new File(name);
if (!f.exists()) {
try {
f.createNewFile();
System.out.println("Datei " + name + " erstellt");
} catch (Exception e) {
System.out.println("Fehler beim Erstellen der Datei " +name);
}
}
Das Erzeugen eines File-Objekts führt noch nicht zum Erstellen einer Datei mit dem als Konstruk-
tor-Parameter verwendeten Namen. Ebenso wird eine bereits vorhandene Datei nicht geöffnet,
wenn ihr Name als Aktualparameter in einem File-Konstruktor auftritt.

14.2.2.3 Informationen über Dateien und Ordner ermitteln


Neben isDirectory() kennen File-Objekte noch weitere Informationsmethoden, z. B.:
• public String getAbsolutePath()
Ermittelt den absoluten Pfadnamen
• public long lastModified()
Ermittelt den Zeitpunkt der letzten Änderung, gemessen in Millisekunden seit dem 1. Januar
1970 (00:00:00 GMT)
• public long length()
Stellt die Größe einer Datei in Bytes fest
• public boolean canWrite()
Prüft, ob das Programm (die JVM) schreibend auf eine Datei zugreifen darf
• public long getUsableSpace()
Schätzt das in einem Verzeichnis (also in der zugehörigen Partition) durch den aktuellen
Anwender (unter Berücksichtigung seiner Schreibrechte) nutzbare Speichervolumen in
Bytes
Hier werden die Anfragen an ein File-Objekt gerichtet, das eine Datei repräsentiert:
System.out.println("Eigenschaften der Datei " + f.getName());
System.out.println(" Vollst. Pfad: " + f.getAbsolutePath());
DateFormat df = DateFormat.getInstance();
String time = df.format(new Date(f.lastModified()));
System.out.println(" Letzte Änderung: " + time);
System.out.println(" Größe in Bytes: " + f.length());
System.out.println(" Schreiben möglich: " + f.canWrite()+"\n");
Abschnitt 14.2 Verwaltung von Dateien und Verzeichnissen 709

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.

14.2.2.4 Atribute ändern


Man kann etliche Attribute von Dateien oder Ordnern ändern, z. B.:
• boolean setLastModified(long time)
Legt für eine Datei oder einen Ordner den Zeitpunkt der letzten Änderung neu fest
• boolean setWritable(boolean writable)
Setzt oder entfernt den Schreibschutz
Im folgenden Beispiel werden die Anforderungen an ein File-Objekt gerichtet, das eine Datei reprä-
sentiert:
Date d = null;
DateFormat df = new SimpleDateFormat("dd.MM.yyyy HH:mm:ss");
try {
d = df.parse("24.12.2084 16:15:00");
} catch (Exception e) {
System.err.println("Fehler bei der Datumsinterpretation");
d = new Date();
}
f.setLastModified(d.getTime());
f.setWritable(false);
Windows hat nichts dagegen, das Änderungsdatum in die Zukunft zu verlegen:

14.2.2.5 Verzeichnisinhalte auflisten


Im folgenden Codefragment wird das File-Objekt curDir mit der Botschaft listFiles() beauftragt,
für jeden Eintrag im aktuellen Verzeichnis ein Element im File-Array files anzulegen:
File curDir = new File(".");
File[] files = curDir.listFiles();
System.out.println("Einträge im aktuellen Verzeichnis:");
for (File fi : files)
System.out.println(" " + fi.getName());
710 Kapitel 14 Ein- und Ausgabe über Datenströme

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.*;

class FNFilter implements FilenameFilter {


private String ext;

public FileFilter(String ext_) {ext = ext_;}

@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 Klassen zur Verarbeitung von Byte-Strömen


In Java 1.0 stammten alle Ein-/Ausgabeklassen von InputStream oder OutputStream ab. Diese
Klassen haben sich zur Ein- bzw. Ausgabe von Bytes, primitiven Datenwerten und Objekten be-
währt, aber bei der Behandlung von Unicode-Zeichen und vor allem beim Internationalisieren von
Java-Software Probleme bereitet. Mit Java 1.1 wurden daher zur Verarbeitung von Textdaten die
neuen Basisklassen Reader und Writer mit ihren Klassenhierarchien eingeführt. Für die Ein- bzw.
Ausgabe von Bytes, primitiven Datenwerten und Objekten sind aber nach wie vor die von In-
putStream bzw. OutputStream abstammenden, byte-orientierten Klassen adäquat.
An einigen Stellen haben alte Lösungen zur Zeichenverarbeitung überlebt, z. B. die von Output-
Stream (indirekt) abstammende und bei der Standard(fehler)ausgabe eines Java-Programms betei-
ligte Klasse PrintStream (siehe Abschnitt 14.3.1.6).

14.3.1 Die OutputStream-Hierarchie

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

• Mit der Transformationsklasse ObjectOutputStream können komplette Objekte in einen


Ausgabestrom geschrieben werden. Sie wird zusammen mit ihrem Gegenstück Object-
InputStream im Abschnitt 14.6 über die Objekt(de)serialisation behandelt.
Mit den folgenden Klassen werden wir uns im Manuskript nicht näher beschäftigen:
• Objekte der Ausgabeklasse ByteArrayOutputStream schreiben Bytes in einen byte-Array,
also in eine programminterne Senke. Man kann sie z. B. zusammen mit dem Gegenstück
ByteArrayInputStream und dem eben erwähnten Duo aus einem ObjectOutputStream
und einem ObjectInputStream dazu verwenden, um per Objekt(de)serialisation eine tiefe
Objektkopie (inkl. referenzierter Member-Objekte) zu erstellen (siehe z. B. Krüger &
Hansen 2014, S. 881ff).
• Objekte der Filterklasse DeflaterOutputStream aus dem Paket java.util.zip komprimieren
einen Ausgabestrom. Mit der von DeflaterOutputStream abgeleiteten Klasse
ZipOutputStream lassen sich neue Einträge im einem ZIP-Archiv erstellen (siehe z. B.
Krüger & Hansen 2014, S. 425f).
Als Ausgabemethoden stehen in der Klasse OutputStream zur Verfügung:
• public void write(int b) throws IOException
Es wird ein Byte geschrieben, wobei von den 4 Bytes des Aktualparameters nur das nieder-
wertigste Byte verwendet wird.
• public void write(byte[] b) throws IOException
Der komplette byte-Array wird geschrieben.
• public void write(byte[] b, int offset, int len) throws IOException
Aus dem byte-Array werden len Bytes beginnend mit der Position offset geschrieben.

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

byte, byte[] FileOutputStream Bytes Binärdatei

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

• das im ersten Parameter angegebene Dateisystemobjekt ein Ordner ist,


• die Ausgabedatei vorhanden ist, aber nicht zum Schreiben geöffnet werden kann,
• das automatische Erstellen der nicht vorhandenen Ausgabedatei misslingt.
Mit dem append-Aktualparameterwert true sorgt man dafür, dass die Ausgaben bei einer vorhan-
denen Datei am Ende angehängt werden. Anderenfalls wird eine vorhandene Ausgabedatei über-
schrieben.
Soll ein FileOutputStream unter Verwendung einer per Path-Objekt identifizierten Datei instan-
ziert werden, dann bietet sich die Path-Methode toFile() an, die zu einem Path-Objekt ein korres-
pondierendes File-Objekt liefert (siehe Abschnitt 14.2.1.1).
Weil FileOutputStream-Objekte nur byte-Variablen oder -Arrays befördern können (über die von
OutputStream geerbten Methoden, siehe Abschnitt 14.3.1.1), werden sie oft mit Filterobjekten
(z. B. aus der Klasse DataOutputStream) kombiniert, die flexiblere Ausgabemethoden besitzen
(siehe Abschnitt 14.3.1.4). Im folgenden Beispielprogramm ist diese Einschränkung jedoch irrele-
vant. Es demonstriert, welchen früher üblichen Aufwand beim Kopieren von Dateien man sich heu-
te durch die Verwendung der Files-Methode copy() sparen kann (vgl. Abschnitt 14.2.1.9). Während
das Programm nicht mehr als Muster für das Kopieren von Dateien taugt, demonstriert es doch die
weiterhin relevante Verwendung eines FileOutputStream-Objekts zum Schreiben in eine Binärda-
tei. Außerdem wird auch gleich die Verwendung eines FileInputStream-Objekts zum Lesen aus
einer Binärdatei vorgeführt (vgl. Abschnitt 14.3.2.2):
import java.io.*;

class FileCopy {
final static String QUELLE = "quelle.dat", ZIEL = "ziel.dat";
final static int BUFLEN = 1048576; // 1 Megabyte (1024*1024 Bytes) als Puffergröße

public static void main(String[] args) throws IOException {


byte[] buffer = new byte[BUFLEN];
int nread;
long zeit, total = 0;
try (FileInputStream fis = new FileInputStream(QUELLE);
FileOutputStream fos = new FileOutputStream(ZIEL)) {
zeit = System.currentTimeMillis();
System.out.println("Kopieren von " + QUELLE + " in " + ZIEL + " gestartet");
for(int i = 1; ; i++) {
nread = fis.read(buffer, 0, Math.min(BUFLEN, fis.available()));
if (nread == 0)
break;
else {
fos.write(buffer, 0, nread);
total += nread;
if (total >= BUFLEN) {
String s = i + " Megabyte";
for (int j = 0; j < s.length(); j++)
System.out.print("\b");
System.out.print(s);
}
}
}
zeit = System.currentTimeMillis() - zeit;
System.out.println("\nEs wurden " + total + " Bytes kopiert. "+
"(Benötigte Zeit: " + zeit + " Millisekunden.)");
}
}
}
714 Kapitel 14 Ein- und Ausgabe über Datenströme

In der for-Schleife (ohne Terminierungsbedingung) wird mit der FileInputStream-Methode read()


aus der Quelldatei jeweils ein Megabyte oder aber die per available() - Aufruf ermittelte Restmenge
(vgl. Abschnitt 14.3.2.2) gelesen und anschließend von der FileOutputStream-Methode write() in
die Zieldatei befördert. Per Rückgabewert informiert die FileInputStream-Methode read() dar-
über, wie viele Bytes tatsächlich gelesen wurden. Das Programm protokolliert den Arbeitsfort-
schritt, wobei durch die Ausgabe einer hinreichenden Zahl von Backspace-Steuerzeichen (\b) dafür
gesorgt wird, dass alle Fortschrittsmeldungen in derselben Konsolenzeile erscheinen.1 Nach Ab-
schluss des Kopiervorgangs werden die Transportleistung und die benötigte Zeit protokolliert, z. B.:
Kopieren von quelle.dat in ziel.dat gestartet:
1663 Megabyte
Es wurden 1743651037 Bytes kopiert. (Benötigte Zeit: 34277 Millisekunden.)

14.3.1.3 OutputStream mit Dateianschluss per NIO.2 - API


Von der Klasse Files im Paket java.nio erhält man über die statische Methode
newOutputStream() einen OutputStream, der per Channel-Technik mit einer binären Ausgabeda-
tei verbunden ist:

Programm

OutputStream mit Channel-


byte, byte[] Technik als Ergebnis von Bytes Binärdatei
Files.newOutputStream()

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

• Im Vergleich zum FileOutputStream-Konstruktor bietet die Methode newOutputStream()


differenzierte Öffnungsoptionen für die Datei (siehe Abschnitt 14.7.1).
• Eine Inspektion des API-Quellcodes (in den Klassen Files, FileSystemProvider und Chan-
nels) zeigt, dass im Hintergrund bei den Dateizugriffen die Channel-Technik zum Einsatz
kommt, wobei ein Geschwindigkeitsvorteil möglich, aber nicht garantiert ist.1 Bei manchen
Anforderungsprofilen kann die Channel-Technik sogar ein schlechteres Leistungsverhalten
zeigen als die traditionelle Datenstromtechnik.
• Die gleichzeitige Nutzung durch mehrere Threads (Ausführungsfäden, siehe Kapitel 15) ist
erlaubt.
Ein Einsatzbeispiel für die Methode newOutputStream() war schon im Abschnitt 14.1.2 zu sehen.

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

prim. Datentypen, DataOutput- Output- binäre


String Bytes
Stream Stream Senke

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");

try (DataOutputStream dos = new DataOutputStream(Files.newOutputStream(file))) {


dos.writeInt(4711);
dos.writeDouble(Math.PI);
dos.writeUTF("DataOutputStream-Demo");
}

try (DataInputStream dis = new DataInputStream(Files.newInputStream(file))) {


System.out.println("readInt() - Ergebnis: " + dis.readInt() +
"\nreadDouble() - Ergebnis: " + dis.readDouble() +
"\nreadUTF() - Ergebnis: " + dis.readUTF());
}
}
}
Ein DataInputStream holt in Kooperation mit einem InputStream die Werte zurück (vgl. Ab-
schnitt 14.3.2):
readInt() - Ergebnis: 4711
readDouble() - Ergebnis: 3.141592653589793
readUTF() - Ergebnis: DataOutputStream-Demo

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

byte, BufferedOutput- FileOutput-


Bytes Binärdatei
byte[] Stream Stream

Im BufferedOutputStream-Konstruktor ist obligatorisch ein OutputStream-Objekt zu übergeben


(vgl. Abschnitt 0). Optional kann die voreingestellte Puffergröße von 8192 Bytes geändert werden:
• public BufferedOutputStream(OutputStream out)
• public BufferedOutputStream(OutputStream out, int size)
Zur Ausgabe vom byte-Werten und byte-Arrays stehen die write() - Überladungen der Basisklasse
OutputStream zur Verfügung.
Abschnitt 14.3 Klassen zur Verarbeitung von Byte-Strömen 717

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");

public static void main(String[] args) throws IOException {


long time;

try (OutputStream fos = Files.newOutputStream(AUSGABE)) {


time = System.currentTimeMillis();
for (int i = 1; i <= ANZAHL; i++)
fos.write(i);
System.out.println("Zeit für die ungepufferte Ausgabe: " +
(System.currentTimeMillis() - time));
}

try (BufferedOutputStream bos = new BufferedOutputStream(


Files.newOutputStream(AUSGABE))) {
time = System.currentTimeMillis();
for (int i = 1; i <= ANZAHL; i++)
bos.write(i);
System.out.println("Zeit für die gepufferte Ausgabe: " +
(System.currentTimeMillis() - time));
}
}
}
Durch den Einsatz des BufferedOutputStream-Objekts (mit einer Puffergröße von 8192 Bytes)
kann der Zeitaufwand beim Schreiben erheblich reduziert werden, was sich schon beim Schreiben
auf eine lokale Festplatte zeigt (Angaben in Millisekunden):1
Zeit für die ungepufferte Ausgabe: 212
Zeit für die gepufferte Ausgabe: 3
Noch drastischer ist der Zeitgewinn durch die gepufferten Ausgabe, wenn sich die Ausgabedatei im
Netzwerk befindet:2
Zeit für die ungepufferte Ausgabe: 219817
Zeit für die gepufferte Ausgabe: 29

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 Die InputStream-Hierarchie

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

• Durch ein Tandem aus einem PipedInputStream und einem verbundenen


PipedOutputStream lässt sich ein unidirektionaler Datentransfer zwischen zwei Threads
(Ausführungsfäden) einrichten. Der erste Thread schreibt Bytes in den PipedOutputStream
und der zweite Thread liest aus dem verbundenen PipedInputStream. Im Abschnitt
15.2.4.2 wird die Realisation einer Produzenten-Konsumenten - Kooperation mit der Pipe-
line-Technik beschrieben.
• Mit der Transformationsklasse ObjectInputStream können komplette Objekte aus einem
Eingabestrom gelesen werden. Sie wird zusammen mit ihrem Gegenstück Object-
OutputStream im Abschnitt 14.6 über die Objekt(de)serialisation behandelt.
Die folgenden Klassen können nur kurz vorgestellt werden:
• ByteArrayInputStream
Objekte dieser Eingabeklasse lesen Bytes aus einem byte-Array. Man kann sie z. B. zu-
sammen mit dem Gegenstück ByteArrayOutputStream und dem eben erwähnten Duo aus
einem ObjectInputStream und einem ObjectOutputStream dazu verwenden, um per Ob-
jekt(de)serialisation eine tiefe Objektkopie (inkl. referenzierter Member-Objekte) zu erstel-
len (siehe z. B. Krüger & Hansen 2014, S. 881ff).
• SequenceInputStream
Mit Hilfe dieser Transformationsklasse kann man eine Sequenz von InputStream-Objekten
bilden und als einen einzigen Strom behandeln. Das Lesen startet mit dem ersten Einga-
bestrom. Ist das Ende eines Eingabestroms erreicht, wird er geschlossen, und der nächste
Strom in der Sequenz wird geöffnet. Auf diese Weise kann man z. B. aus einer Serie von
Dateien einen gemeinsamen Eingabestrom erstellen.
• BufferedInputStream
Analog zum BufferedOutputStream (siehe Abschnitt 14.3.1) realisiert diese Eingabetrans-
formationsklasse einen Zwischenspeicher, um das Lesen aus einem Eingabestrom zu be-
schleunigen.
• Objekte der Filterklasse InflaterInputStream aus dem Paket java.util.zip dekomprimieren
einen Eingabestrom. Mit der von InflaterInputStream abgeleiteten Klasse
ZipInputStream. kann man Dateien aus einem ZIP-Archiv lesen (siehe z. B. Krüger &
Hansen 2014, S. 433f).
• PushbackInputStream
Diese Transformationsklasse bietet Methoden, um aus einem Eingabestrom entnommene
Bytes wieder zurückzustellen, was z. B. dann sinnvoll ist, wenn nach einer vorausschauen-
den Prüfung die eigentliche Verarbeitung durch ein anderes Stromobjekt erfolgen soll.
Ein InputStream-Objekt beherrscht die folgenden Methoden zum Lesen von Daten:
• public int read() throws IOException
Als Rückgabewert (vom Typ int!) erhält man das nächste Byte (mögliche Werte von 0 bis
255) oder den Wert -1, falls das Dateiende erreicht ist.
• public int read(byte[] b) throws IOException
Diese Methode überträgt maximal b.length Bytes aus der Eingabedatei in den per Parameter
angegebenen byte-Array. Als Rückgabewert erhält man die Anzahl der gelesenen Bytes
oder -1, falls das Ende des Eingabestroms erreicht ist.
• public int available() throws IOException
Laut API-Dokumentation schätzt available(), wie viele Bytes eine Methode aus dem Strom
lesen kann, ohne auf Daten warten zu müssen.
722 Kapitel 14 Ein- und Ausgabe über Datenströme

14.3.2.2 FileInputStream
Mit einem FileInputStream kann man Bytes aus einer Datei lesen:

Programm

byte, FileInputStream Bytes Binärdatei


byte[]

Ein Beispielprogramm mit FileInputStream-Beteiligung war schon im Abschnitt 14.3.1.2 (über


den FileOutputStream) zu sehen.
Im FileInputStream-Konstruktor kann die anzusprechende Datei über ein File-Objekt (siehe Ab-
schnitt 14.2.2) oder über einen String festgelegt werden:
• public FileInputStream(File file)
• public FileInputStream(String name)
Scheitert das Öffnen wegen einer nicht-existenten Datei oder aus einem anderen Grund, dann wer-
fen die Konstruktoren eine geprüfte Ausnahme vom Typ FileNotFoundException.
Soll ein FileInputStream unter Verwendung einer per Path-Objekt identifizierten Datei instanziert
werden, dann bietet sich die Path-Methode toFile() an, die zu einem Path-Objekt ein korrespondie-
rendes File-Objekt liefert (siehe Abschnitt 14.2.1.1).
Im folgenden Codefragment werden die drei im Abschnitt 14.3.2.1 beschriebenen Methoden zum
Lesen von Daten verwendet:
int anfang, gelesen;
byte[] rest = new byte[10];
try (FileInputStream fis = new FileInputStream("demo.bin")) {
System.out.println("Verfügbar in Datei: " + fis.available());
anfang = fis.read();
System.out.println("Verfügbar nach read(): " + fis.available());
gelesen = fis.read(rest);
System.out.print("Eingelesen: " + anfang);
for (int i = 0; i < gelesen; i++)
System.out.print(rest[i]);
}
Mit einer binären Eingabedatei, die 8 Bytes mit den Werten 0 bis 7 enthält, kommt die folgende
Ausgabe zustande:
Verfügbar in Datei: 8
Verfügbar nach read(): 7
Eingelesen: 01234567
Abschnitt 14.3 Klassen zur Verarbeitung von Byte-Strömen 723

14.3.2.3 InputStream mit Dateianschluss per NIO.2 - API


Von der Klasse Files im Paket java.nio erhält man über die statische Methode newInputStream()
einen InputStream, der per Channel-Technik mit einer binären Eingabedatei verbunden ist:

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

Im DataInputStream-Konstruktor ist der zugrunde liegende Eingabestrom anzugeben:


public DataInputStream(InputStream in)
Erläuterungen zu den diversen Lesemethoden (z. B. readInt(), readDouble()) finden Sie in der
API-Dokumentation. Mit readUTF() steht auch eine Methode zum Lesen von Zeichen bereit, wo-
bei eine modifizierte Variante der UTF-8 - Codierung (vgl. Abschnitt 14.4.1.2) vorausgesetzt wird.
Diese Methode ist nur dann angemessen, wenn die Zeichen mit der DataOutputStream-Methode
writeUTF() geschrieben worden sind (vgl. Abschnitt 14.3.1.4).

14.4 Klassen zur Verarbeitung von Zeichenströmen


Java verwendet intern zur Repräsentation von Zeichen den Unicode, und die Klassen zur Verarbei-
tung von Zeichenströmen müssen beim Lesen und Schreiben zwischen der internen Repräsentation
und der extern benötigten (z. B. der lokalüblichen) Zeichencodierung vermitteln.
In diesem Abschnitt werden die mit Java 1.1 eingeführten Klassen zur Verarbeitung von Zeichen-
strömen behandelt, die von den beiden abstrakten Basisklassen Writer bzw. Reader abstammen
(siehe Abschnitt 14.1.3). Sie sind bei der Verarbeitung von Textdaten vor allem wegen der unprob-
lematischen Internationalisierung gegenüber den (schon seit Java 1.0 vorhandenen) Bytestromklas-
sen DataOutputStream, DataInputStream und PrintStream zu bevorzugen.

14.4.1 Die Writer-Hierarchie

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

Writer OutputStreamWriter FileWriter

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.

14.4.1.2 Brückenklasse OutputStreamWriter


Die Klasse OutputStreamWriter überträgt die von Java intern verwendeten Unicode-Zeichen in
einen Byte-Strom, wobei unterschiedliche Codierungen (engl. encodings) unterstützt werden. Weil
ein OutputStreamWriter einen Zeichenstrom in einen Bytestrom überführt, spricht die API-
Dokumentation hier von einer Brückenklasse.
726 Kapitel 14 Ein- und Ausgabe über Datenströme

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

String, OutputStream- FileOutput-


Bytes Textdatei
char Writer Stream

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;

static void ohneBufferedWriter() throws IOException {


for (int i = 0; i < 2; i++) {
try (FileWriter fw = new FileWriter("test.txt")) {
long time = System.currentTimeMillis();
for (int j = 1; j <= ANZAHL; j++)
fw.write("Zeile " + i + System.lineSeparator());
if (i == 1)
System.out.println("Benötigte Zeit ohne BufferedWriter: "
+ (System.currentTimeMillis() - time));
}
Files.delete(Paths.get("test.txt"));
}
}

static void mitBufferedWriter() throws IOException {


for (int i = 0; i < 2; i++) {
try (BufferedWriter bw = new BufferedWriter(new FileWriter("test.txt"))) {
long time = System.currentTimeMillis();
for (int j = 1; j <= ANZAHL; j++)
bw.write("Zeile " + i + System.lineSeparator());
if (i == 1)
System.out.println("Benötigte Zeit mit BufferedWriter: "
+ (System.currentTimeMillis() - time));
}
Files.delete(Paths.get("test.txt"));
}
}

public static void main(String[] args) throws IOException {


ohneBufferedWriter();
mitBufferedWriter();
}
}

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

Die etwas performantere Datenstrom-Pipeline hat den folgenden Aufbau:

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

String BufferedWriter FileWriter Bytes Textdatei

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);
}

public PrintWriter(OutputStream out, boolean autoFlush) {


this(out, autoFlush, Charset.defaultCharset());
}

public PrintWriter(OutputStream out, boolean autoFlush, Charset charset) {


this(new BufferedWriter(new OutputStreamWriter(out, charset)), autoFlush);
. . .
}
Damit arbeitet insgesamt das folgende Gespann:

Print- Buffered- OutputStream- Output-


Writer Writer Writer Stream

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

Während bei der PrintWriter-OutputStream - Konstruktion im Hintergrund ein BufferedWriter


und ein OutputStreamWriter mit Pufferung beteiligt sind (siehe oben), puffert bei der PrintWri-
ter-FileWriter - Konstruktion nur die OutputStreamWriter-Ableitung FileWriter. Wegen des
beschränkten Effekts einer zusätzlichen BufferedWriter-Beteiligung spielt das keine große Rolle
(siehe Abschnitt 14.4.1.4).

14.4.1.6 BufferedWriter mit Dateianschluss per NIO.2 - API


Wer beim gepufferten Schreiben von Zeichen die Verbindung zur Textdatei über das NIO.2 - API
(vgl. Abschnitt 14.2.1) herstellen möchte, kann bei der Klasse Files im Paket java.nio über die sta-
tischen Methode newBufferedWriter() einen BufferedWriter anfordern, der per Channel-Technik
mit einer Ausgabedatei verbunden ist:

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 Die Reader-Hierarchie


In der Reader-Hierarchie finden sich diverse Klassen zur Verarbeitung von zeichenorientierten
Eingabeströmen.

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.

14.4.2.2 Brückenklasse InputStreamReader


Die Brückenklasse InputStreamReader ist das Gegenstück zur Klasse OutputStreamReader,
wandelt also Bytes unter Verwendung einer einstellbaren Codierung (vgl. Abschnitt 14.4.1.2) in
Unicode-Zeichen, sodass z. B. beim Lesen aus einer Textdatei die folgende Verarbeitungskette ent-
steht:
736 Kapitel 14 Ein- und Ausgabe über Datenströme

Programm

String, InputStream- File-


Bytes Textdatei
char Reader InputStream

Wie beim OutputStreamReader findet zur Beschleunigung der Konvertierung automatisch eine
Pufferung des Byte-Stroms statt.

14.4.2.3 FileReader und BufferedReader


Die Klasse FileReader ist das Gegenstück zur Klasse FileWriter. Sie wird mit einem schraffierten
Hintergrund dargestellt (siehe z. B. Reader-Hierarchie im Abschnitt 14.4.2.1), weil ihre Objekte …
• mit einer Datei in Kontakt stehen (über ein Instanzobjekt aus der Klasse FileInputStream),
sodass sie als Eingabestrom arbeiten können,
• als InputStreamReader-Abkömmlinge den eingehenden Byte-Strom in einen Zeichen-
strom transformieren, sodass sie als Filterobjekte bezeichnet werden können.
Bei der Wandlung von Bytes in Unicode-Zeichen kommt die voreingestellte Codierung zum Einsatz
(siehe Abschnitt 14.4.1.2). Wer eine alternative Codierung benötigt, muss auf die FileReader-
Bequemlichkeit verzichten, einen InputStreamReader mit passender Codierung erstellen und mit
einem InputStream kombinieren.
In der Regel setzt man vor den FileReader noch einen BufferedReader, der für eine Beschleuni-
gung sorgt und außerdem die bei Dateien mit Zeilenstruktur sehr nützliche Methode readLine()
zum Einlesen einer kompletten Zeile bietet, z. B.:
Quellcode Ausgabe
import java.io.*; Zeile 100
import java.util.*;

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));
}
}
}

Das Beispielprogramm arbeitet mit der folgenden Datenstrom-Architektur:


Abschnitt 14.4 Klassen zur Verarbeitung von Zeichenströmen 737

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).

14.4.2.4 BufferedReader mit Dateianschluss per NIO.2 - API


Wer beim gepufferten Lesen von Zeichen die Verbindung zur Textdatei über das NIO.2 - API (vgl.
Abschnitt 14.2.1) herstellen möchte, kann bei der Klasse Files im Paket java.nio über die statische
Methode newBufferedReader() einen BufferedReader anfordern, der per Channel-Technik mit
einer Eingabedatei verbunden ist:

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

Im Vergleich zu einem traditionell durch Konstruktoraufrufe erzeugten Gespann aus BufferedRea-


der und FileReader (vgl. Abschnitt 14.4.2.3) 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.
• Über das im Hintergrund per newInputStream() erzeugte Eingabeobjekt kommt die Chan-
nel-Technik zum Einsatz, wobei ein Geschwindigkeitsvorteil möglich, aber nicht garantiert
ist. Bei manchen Anforderungsprofilen kann die Channel-Technik sogar ein schlechteres
Leistungsverhalten zeigen als die traditionelle Datenstromtechnik.
Für eine Textdatei mit UTF-8 - Codierung eignet sich die folgende newBufferedReader() - Über-
ladung ohne Charset-Parameter:
public static BufferedReader newBufferedReader(Path path)
throws IOException
Das Beispielprogramm aus dem Abschnitt 14.4.2.3 lässt sich mit Hilfe der Files-Methode
newBufferedReader() etwas vereinfachen:
import java.io.*;
import java.nio.file.*;
import java.util.*;

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.

14.5 Zahlen und Zeichenfolgen aus einer Textdatei lesen


Das Lesen von numerischen Daten aus Textdateien ist sehr oft erforderlich, doch die im Verlauf des
aktuellen Kapitels vorgestellten Klassen sind für diese Aufgabe nicht geeignet:
• Ein DataInputStream-Objekt liefert z. B. über die Methoden readInt() und readDouble()
Zahlen ab, erwartet aber eine binäre, analog zum Hauptspeicher organisierte Eingabe (siehe
Abschnitt 14.3.2.4).
• Ein BufferedReader-Objekt kann eine Textdatei verarbeiten, liefert aber (z. B. über die Me-
thode readLine()) nur Zeichenfolgen ab, die erst noch numerisch interpretiert werden müs-
sen.
Offenbar fehlt in der Reader-Hierarchie ein Analogon zur Klasse DataInputStream.
Abschnitt 14.5 Zahlen und Zeichenfolgen aus einer Textdatei lesen 739

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

• public Scanner(File source) throws FileNotFoundException


• public Scanner(File source, String charsetName) throws FileNotFoundException
• public Scanner(Path source) throws IOException
• public Scanner(Path source, String charsetName) throws IOException
• public Scanner(InputStream source)
• public Scanner(InputStream source, String charsetName)
Weil der Parameter charsetName an die statische Methode forName() der Klasse Charset im Paket
java.nio.charset übergeben wird, ist unter den für das java.nio - API vorgesehenen Codierungsna-
men zu wählen (siehe Abschnitt 14.4.1.2).
Obwohl die Klasse Scanner weder von InputStream noch von Reader abstammt, benutzen wir
doch die im aktuellen Kapitel eingeführte Darstellung der Funktionsweise:

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.*;

public class Simput {


static private boolean error;
static private String errorDescription = "";

static public int gint() {


int eingabe = 0;
@SuppressWarnings("resource")
Scanner input = new Scanner(System.in);
try {
eingabe = input.nextInt();
error = false;
errorDescription = "";
} catch(Exception e) {
error = true;
errorDescription = e.getMessage();
System.out.println("Falsche Eingabe!\n");
}
return eingabe;
}
. . .
}
742 Kapitel 14 Ein- und Ausgabe über Datenströme

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;
}

public static void main(String[] egal) {


double[] da = extractData(" 1,2 3,5 5k8", 4, -9.9);
for(double d : da)
System.out.println(d);
}
}

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

14.6.1 Objektserialisierung und Sicherheit


Auffälligster Bestandteil in der API-Dokumentation zur Schnittstelle Serializable ist die folgende
Warnung:2
Warning: Deserialization of untrusted data is inherently dangerous and should be avoided.
Untrusted data should be carefully validated according to the "Serialization and Deseriali-
zation" section of the Secure Coding Guidelines for Java SE. Serialization Filtering de-
scribes best practices for defensive use of serial filters.
Wer aus unsicherer Quelle stammende Byte-Ströme bedenkenlos deserialisiert, riskiert böswillige
Angriffe aller Art durch manipulierte Ströme (siehe z. B. Bloch 2018, S. 339ff), z. B.:
• Remote-Code-Execution (RCE)
• Denial-of-Service (DoS)
Dabei werden Ströme konstruiert, die einen sehr großen Deserialisierungsaufwand
verursachen.
Bloch (2018) empfiehlt:
• Nach Möglichkeit sollte auf die Java-Serialisierung verzichtet werden. Durch die im Ab-
schnitt 14.6.9 als Alternativen zur Java-Serialisierung zur Überwindung von Kompatibili-
täts- und Performanz-Problemen beschriebenen Techniken (JSON, Protocol Buffers) lassen
sich auch die Sicherheitsprobleme lösen.
• Wenn z. B. ein Framework zur Verwendung der Serialisierung zwingt, muss beim Deseriali-
sieren ein hoher Sicherheitsaufwand betrieben werden.
Zur Erstellung einer tiefen Objektkopie ist die (De)serialisierung aber weiterhin geeignet, weil da-
bei nur ein interner byte-Array als Eingabestrom genutzt wird.

14.6.2 Beispiel für eine serialisierbare Klasse


Voraussetzungen für die Serialisierbarkeit einer Klasse sind:
• Die Klasse muss das Marker-Interface Serializable implementieren.
Diese Schnittstelle deklariert keinerlei Methoden, und das Implementieren ist als Einver-
ständniserklärung zu verstehen.
• Enthält eine Klasse Instanzobjekte, dann müssen auch deren Klassen mit der Serialisierung
einverstanden sein. Anderenfalls kommt es zu einer NotSerializableException, die aller-
dings verhindert werden kann, indem betroffene Instanzvariablen als transient deklariert
werden (siehe Abschnitt 14.6.5).

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;

public class Kunde implements Serializable {


private static final long serialVersionUID = 1L;
private String vorname;
private String name;
private int stimmung;
private int nkaeufe;
private BigDecimal aussen;

public Kunde(String vorname_, String name_, int stimmung_,


int nkaeufe_, BigDecimal aussen_) {
vorname = vorname_;
name = name_;
stimmung = stimmung_;
nkaeufe = nkaeufe_;
aussen = aussen_;
}

public void prot() {


System.out.println("Name: " + vorname + " " + name);
System.out.println("Stimmung: " + stimmung);
System.out.println("Anz. Einkäufe: " + nkaeufe +
", Außenstände: " + aussen+ "\n");
}
}
Für die Summe der Außenstände eines Kunden wird zur Vermeidung von Rundungsungenauigkei-
ten an Stelle einer Gleitkommavariablen ein Objekt der Klasse BigDecimal verwendet. Diese Pra-
xis ist bei allen finanzmathematischen Anwendungen generell zu empfehlen (vgl. Abschnitt
3.3.7.2).

14.6.3 Versionskontrolle und Kompatibilitätsprobleme


Durch Änderungen an der Klassendefinition kann es zu Inkompatibilitäten mit vorhandenen seriali-
sierten Objekten kommen. Um damit zusammenhängende Probleme zu verhindern, wird beim Seri-
alisieren stets eine Versionsangabe zur Klasse abgespeichert. Per Voreinstellung wird eine automa-
tisch aus der Klassendefinition berechnete Versionsangabe verwendet. Weil sich diese Versionsan-
gabe auch bei Serialisierungs-irrelevanten Modifikationen der Klassendefinition (z. B. durch die
Ergänzung einer Methode) ändert, kommt es zu unnötigen Einschränkungen bei der Deserialisie-
rung.
Um dies zu verhindern, definiert man eine private und statische long-Variable mit dem Namen
serialVersionUID und ändert deren Wert nur bei einer Serialisierungs-relevanten Modifikationen
der Klassendefinition, z. B.:
Abschnitt 14.6 Objektserialisierung 745

public class Kunde extends Person implements Serializable {


private static final long serialVersionUID = 1L;
. . .
}

Ü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.

14.6.4 Objekte in eine Datei schreiben und von dort lesen


Für das Schreiben von Objekten ist die byte-orientierte Ausgabetransformationsklasse ObjectOut-
putStream zuständig, für das Lesen die byte-orientierte Eingabetransformationsklasse ObjectIn-
putStream. Einen komplexen Objektgraphen in einen Byte-Strom zu verwandeln bzw. von dort zu
rekonstruieren, ist eine anspruchsvolle Aufgabe, die aber in der Regel automatisiert und erfolgreich
abläuft.
Im folgenden Beispielprogramm wird ein Objekt der serialisierbaren Klasse Kunde mit der Object-
OutputStream-Methode writeObject()
public final void writeObject (Object obj) throws IOException
in eine Datei befördert und anschließend mit der ObjectInputStream-Methode readObject()
public final Object readObject() throws IOException, ClassNotFoundException
von dort zurückgeholt. Außerdem wird demonstriert, dass die beiden Klassen Object-
OutputStream bzw. ObjectInputStream auch Methoden zum Schreiben bzw. Lesen von primiti-
ven Datentypen besitzen (z. B. writeInt() bzw. readInt()):
746 Kapitel 14 Ein- und Ausgabe über Datenströme

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);
}

try (ObjectInputStream ois = new ObjectInputStream(


new FileInputStream("test.ser"))) {
int anzahl = ois.readInt();
Kunde unbekannt = (Kunde) ois.readObject();
System.out.printf("Nummer des deserialisierten Falles: %d\n\n", anzahl);
unbekannt.prot();
}
}
}

Das Programm liefert die folgende Ausgabe:


Zu sichern:

Name: Fritz Orth


Stimmung: 1
Anz. Einkäufe: 13, Außenstände: 426.89

Nummer des deserialisierten Falles: 1

Name: Fritz Orth


Stimmung: 1
Anz. Einkäufe: 13, Außenstände: 426.89

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

Objekte ObjectInput- FileInput-


Bytes Binärdatei
Stream Stream

14.6.5 Von der Serialisierung ausgeschlossene Felder


In die Serialisierung werden alle Instanzvariablen (unabhängig von der Sichtbarkeit) einbezogen,
die nicht als transient deklariert sind. Der Modifikator transient eignet sich z. B. für ...
• Variablen, die aus Datenschutzgründen nicht in einer Datei gespeichert werden sollen,
• Variablen vom Typ einer nicht-serialisierbaren Klasse (z. B. Thread, Socket),
• Rekonstruierbare Variablen,1
• Variablen mit temporären Inhalten, die auf jeden Fall nach dem Deserialisieren neu gesetzt
werden müssen.
Wird in der Klasse Kunde die Instanzvariable stimmung als transient deklariert,
private transient int stimmung;
dann liefert das Beispielprogramm die folgende Ausgabe:

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:

Name: Fritz Orth


Stimmung: 1
Anz. Einkaeufe: 13, Aussenstaende: 426.89

Nummer des deserialisierten Falles: 1

Name: Fritz Orth


Stimmung: 0
Anz. Einkaeufe: 13, Aussenstaende: 426.89
Die Instanzvariable stimmung des eingelesenen Objektes besitzt den int-Initialwert 0, während die
übrigen Instanzvariablen über beide Serialisierungsschritte hinweg ihre Werte behalten haben.

14.6.6 Mehr Kontrolle und Eigenverantwortung


Obwohl die (De)serialisierungs-Automatik in Java ihre komplexe Aufgabe gut erledigt, gibt es doch
einige Anlässe für ergänzende Arbeiten, z. B.
• Initialisierung von transienten Variablen nach der Deserialisierung (siehe Abschnitt 14.6.5)
• Unterstützung von verschiedenen Versionen einer Klasse (mit unterschiedlichen
serialVersionUIDs) (siehe Abschnitt 14.6.3)
• Versorgung von Erbstücken aus einer nicht-serialisierbaren Oberklasse (siehe Abschnitt
14.6.7)
Um bei der (De)serialisierung ein Wörtchen mitreden zu können, muss man in der serialisierbaren
Klasse die Methoden writeObject() und readObject() mit den folgenden Definitionsköpfen im-
plementieren:
• private void writeObject(ObjectOutputStream oos) throws IOException
• private void readObject(ObjectInputStream ois) throws IOException,
ClassNotFoundException
Diese Methoden dürfen nicht mit den gleichnamigen Methoden der Klassen ObjectOutputStream
bzw. ObjectInputStream verwechselt werden (vgl. Abschnitt 14.6.4).
Sind die Methoden writeObject() und readObject() in einer serialisierbaren Klasse implementiert,
dann werden sie bei der Serialisierung automatisch aufgerufen. Diese Methoden sind dann für alle
in der Klasse definierten Instanzvariablen zuständig, während eventuell vorhandene geerbte In-
stanzvariablen weiterhin von der Serialisierungsautomatik versorgt werden. Zu Beginn von
writeObject() bzw. readObject() sollte auf jeden Fall über die Methoden defaultWriteObject()
bzw. defaultReadObject() die Standard(de)serialisierung der klasseneigenen Instanzvariablen an-
gefordert werden. In writeObject() werden dann nach Bedarf durch Überladungen der ObjectOut-
putStream-Methode writeObject() weitere Daten in den Ausgabestrom geschrieben. In
readObject() werden nach dem obligatorischen defaultReadObject() - Aufruf nach Bedarf durch
Überladungen der ObjectInputStream-Methode readObject() weitere Daten aus dem Einga-
bestrom gelesen. Wenn die Serialisierung zum Einsatz kommt, obwohl die im Abschnitt 14.6.1 be-
schriebenen Sicherheitsrisiken nicht auszuschließen sind, dann sollte die Methode readObject()
analog zu einem öffentlichen Konstruktor die Gültigkeit des deserialisierten Objekts überprüfen.
Das Implementieren von writeObject() und readObject() ist insbesondere dann zu empfehlen,
wenn sich die physikalische Repräsentation der Daten (z. B. doppelt verkettete Liste mit Zeichen-
folgen) von der logischen Struktur (z. B. Liste mit Zeichenfolgen) unterscheidet (Bloch 2018, S.
348ff). Im Beispiel sollten die Zeichenfolgen seriell in den Strom geschrieben werden, statt den
komplexen Objektgraphen von der Automatik behandeln zu lassen. Nach den Beobachtungen von
Bloch benötigt die Automatik im Beispiel doppelt so viel Zeit und Speichervolumen, wobei es ab
Abschnitt 14.6 Objektserialisierung 749

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.

14.6.7 Serialisierung und Vererbung


Eine (direkte oder indirekte) Ableitung einer serialisierbaren Klasse ist ebenfalls serialisierbar. Um
ein Serialisieren doch zu verhindern, kann man in der betroffenen Klasse die Methoden
writeObject() und readObject() realisieren (siehe Abschnitt 14.6.6) und dabei durch das Werfen
einer Ausnahme den Dienst verweigern.
Trotz der Existenz einer nicht-serialisierbaren Klasse in der Ahnenreihe darf eine Klasse das Inter-
face Serializable implementieren. In diesem Fall werden allerdings die von der nicht-serialisier-
baren Klasse geerbten Instanzvariablen nicht abgespeichert. Bei der Deserialisierung wird der pa-
rameterfreie 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 Reflexion mit den aus dem Datenstrom entnommenen Werten versorgt.
Um zusätzliche Maßnahmen zum Kompensieren der fehlenden Serialisierbarkeit von einigen In-
stanzobjekten zu ergreifen, kann man in der serialisierbaren Klasse die Methoden writeObject()
und readObject() implementieren (siehe Abschnitt 14.6.6). Enthält die serialisierbare Klasse z. B.
ein Instanzobjekt der (nicht-serialisierbaren) Klasse Socket aus dem Paket java.net zur Kommuni-
kation mit einem Server via Internet, dann kann statt des referenzierten Objekts die Serveradresse
und die Portnummer abspeichern und beim Deserialisieren mit Hilfe dieser Daten ein neues Socket-
Objekt erstellen.
Zur Demonstration der Methoden writeObject() und readObject() durch ein einfaches Beispiel
verlagern wir aus der Klasse Kunde (siehe Abschnitt 14.6.2) die Instanzvariablen vorname und
name
public class Kunde extends Person implements Serializable {
private static final long serialVersionUID = 1L;
private transient int stimmung;
private int nkaeufe;
private BigDecimal aussen;
. . .
}

in die nicht-serialisierbare Basisklasse Person:


public class Person {
protected String vorname;
protected String name;
}
Das (De)serialisations-Testprogramm zeigt, dass beim aktuellen Entwicklungsstand der Klasse
Kunde die Werte der Person-Instanzvariablen verloren gehen:
Zu sichern:

Name: Fritz Orth


Stimmung: 1
Anz. Einkäufe: 13, Außenstände: 426.89

Nummer des deserialisierten Falles: 1

Name: null null


Stimmung: 0
Anz. Einkäufe: 13, Außenstände: 426.89
750 Kapitel 14 Ein- und Ausgabe über Datenströme

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);
}

private void readObject(ObjectInputStream ois) throws IOException,


ClassNotFoundException {
ois.defaultReadObject();
vorname = (String) ois.readObject();
name = (String) ois.readObject();
}
Nun klappt die (De-)serialisierung wie erwünscht:
Zu sichern:

Name: Fritz Orth


Stimmung: 1
Anz. Einkäufe: 13, Außenstände: 426.89

Nummer des deserialisierten Falles: 1

Name: Fritz Orth


Stimmung: 0
Anz. Einkäufe: 13, Außenstände: 426.89

14.6.8 Serialisierung bei Record-Klassen


Die (De)serialisierung verläuft bei Record-Klassen einfacher und zuverlässiger als bei normalen
Klassen:1
• Die Serialisierung hängt nur von den Komponenten des Records ab und kann nicht durch die
Methoden writeObject() und readObject() beeinflusst werden (vgl. Abschnitt 14.6.6). Sind
diese Methoden vorhanden, werden sie ignoriert.2
• Bei der Deserialisierung von Record-Objekten werden die Ausprägungen der Komponenten
aus dem Eingabestrom ermittelt und an den kanonischen Konstruktor übergeben. Über die
Deserialisierung von normalen Java-Objekten haben wir hingegen erfahren, dass dabei kein
Konstruktor der serialisierbaren Klasse aufgerufen wird, sodass Verstöße gegen Invarianten
der Konstruktoren möglich sind.3

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

14.6.9 Bewertung der Objektserialisierung und mögliche Alternativen


Als Vorteile der Serialisierungslösung im Java-SE - API sind zu nennen:
• Einfache Handhabung
• keine über Java-SE hinausgehenden Bibliotheken erforderlich
• Gute Unterstützung von Objektgraphen
Referenzierte Memberobjekte werden automatisch einbezogen, wobei Objektidentitäten er-
halten bleiben.
Damit ist die (De)serialisierung zum Speichern von Objekten und für den Netzwerktransfer zwi-
schen Java-Programmen in vielen Fällen eine praxistaugliche Lösung. Hinzu kommt die Eignung
der speicher-internen (De-)Serialisierung zum Erstellen einer tiefen Objektkopie.
Allerdings muss die Sicherheitswarnung vor dem Deserialisieren von Objekten aus unsicherer
Quelle beachtet werden, die in fetten Lettern am Anfang der API-Dokumentation zur Schnittstelle
Serializable zu finden ist:1
Warning: Deserialization of untrusted data is inherently dangerous and should be avoided.
Bei einigen speziellen Klassen rät Bloch (2018, S. 243ff) davon ab, das Interface Serializable zu
implementierten:
• Potentielle Basisklassen sollten in der Regel nicht serialisierbar sein, weil anderenfalls abge-
leitete Klassen mit den im Abschnitt 14.6.3 beschriebenen Kompatibilitätsproblemen belas-
tet werden. Aus demselben Grund sollte die Schnittstelle Serializable in der Regel nicht von
anderen Schnittstellen erweitert werden. Wenn allerdings eine abgeleitete Klasse serialisier-
bar sein muss, verursacht eine nicht-serialisierbare Basisklasse einen Zusatzaufwand (siehe
Abschnitt 14.6.7).
• Objekte von nicht-statischen inneren Klassen, lokalen Klassen und anonymen Klassen (tra-
ditionell oder per Lambda-Ausdruck realisiert) eignen sich nicht zum Serialisieren, weil sie
von Compiler generierte Felder mit Referenzen auf umgebende Instanzen oder auf lokale
Variablen im umgebenden Gültigkeitsbereich enthalten.
Nicht zuletzt wegen der weitgehend automatischen Erledigung komplexer Aufgaben kann bei der
(De-)Serialisierung eines umfangreichen Objektgraphen eine große Datenmeng auftreten. Gelegent-
lich besteht eine Möglichkeit zur Reduktion der Datenmenge darin, Member-Objekte durch Daten
mit primitivem Typ zu ersetzen. Dabei steigt allerdings der Entwicklungsaufwand. Das gilt erst
recht für eine Lösung basierend auf dem von Serializable abgeleiteten Interface Externalizable,
wobei das Datenformat vom Entwickler frei definiert werden kann (siehe Inden 2018a, Abschnitt
10.3.4).
Wenn
• andere Programmiersprachen beteiligt sind,
• oder eine sehr hohe Effizienz bei der Übertragung bzw. Speicherung gefragt ist,
dann kommen Alternativen zur Java - Serialisierung in Betracht, wobei vor allem zu nennen sind
(vgl. Mahn 2021):

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);

14.7.2 Lesen und Schreiben von kleinen Dateien


In diesem Abschnitt werden statische Ein-/Ausgabemethoden der Klasse Files vorgestellt, die sich
laut Java-Tutorial (Oracle 2021) im Unterschied zur Verwendung eines Stream-Objekts (siehe Ab-
schnitte 14.3 und 14.4) nur für kleine Dateien eignen.1 Der wesentliche Grund für diese Einschrän-
kung besteht darin, dass alle Daten beim Lesen „in einem Rutsch“ in den Hauptspeicher befördert
bzw. beim Schreiben von dort abgeholt werden. Weil sich alle Daten simultan im Hauptspeicher
befinden, wird dort entsprechend viel Platz benötigt.
Ein Vorteil der anschließend vorgestellten Methoden besteht darin, dass die beteiligten Dateien
nach dem (gelungenen oder gescheiterten) Lesen bzw. Schreiben automatisch geschlossen werden.
Soll eine Datei komplett in einen byte-Array eingelesen werden, dann bietet die statische Files-
Methode readAllBytes() eine bequeme Lösung:
public static byte[] readAllBytes(Path path) throws IOException
Im folgenden Beispiel wird ein Foto aus einer Datei im JPEG-Format in einen byte-Array eingele-
sen:
byte[] imb = Files.readAllBytes(Paths.get("Emma.jpg"));
Mit der Methode readAllLines() befördert man alle Zeilen einer Textdatei in eine Kollektion vom
Typ List<String>:
public static List<String> readAllLines(Path path, Charset cs) throws IOException
Die gewünschte Zeichencodierung wird über ein Charset-Objekt gewählt (siehe Abschnitt
14.4.1.2).
Für eine Textdatei mit UTF-8 - Codierung eignet sich die folgende Überladung ohne Charset-
Parameter:
public static List<String> readAllLines(Path path) throws IOException
Mit der statischen Files-Methode write() befördert man einen byte-Array in eine Datei:
public static Path write(Path path, byte[] buffer, OpenOption... options)
throws IOException

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

14.7.3 Datenstrom zu einem Path-Objekt erstellen


Zu einem Path-Objekt, das eine Datei repräsentiert, kann man über statische Fabrikmethoden der
Klasse Files Datenstromobjekte für Byte- bzw. Zeichenströme erstellen (siehe Abschnitt 14.3 bzw.
14.4):
• public static OutputStream newOutputStream(Path path, OpenOption... options)
throws IOException
Man erhält einen byte-orientieren Ausgabestrom, der mit einer Datei verbunden ist (siehe
Abschnitt 14.3.1.3).
• public static InputStream newInputStream(Path path, OpenOption... options)
throws IOException
Man erhält einen byte-orientieren Eingabestrom, der mit einer Datei verbunden ist (siehe
Abschnitt 14.3.2.3).
• public static BufferedWriter newBufferedWriter(Path path, Charset cs,
OpenOption... options)
throws IOException
Man erhält einen gepufferten, zeichenorientieren Ausgabestrom (siehe Abschnitt 14.4.1.6),
der einen durch die eben vorgestellte Files-Methode newOutputStream() erstellten Out-
putStream für die Verbindung zur Ausgabedatei verwendet.
• public static BufferedReader newBufferedReader(Path path, Charset cs)
throws IOException
Man erhält einen gepufferten zeichenorientieren Eingabestrom (siehe Abschnitt 14.4.2.4)
der einen durch die eben vorgestellte Files-Methode newInputStream() erstellten In-
putStream für die Verbindung zur Eingabedatei verwendet.
Zu newBufferedWriter() und newBufferedReader() existieren Überladungen ohne Charset-
Parameter, wobei die Codierung UTF-8 zum Einsatz kommt.
Im Vergleich zu traditionellen, per Konstruktor erstellten Datenstromobjekten (z. B. aus den Klas-
sen FileOutputStream, FileInputStream, BufferedWriter, BufferedReader) haben die Produkte
der Files-Fabrikmethoden die folgenden Vorteile:
• Bei den Dateizugriffen kommt die Channel-Technik zum Einsatz, wobei ein Leistungsvor-
teil möglich, aber nicht garantiert ist.
• Für die Ausgabedateien können Öffnungsoptionen gesetzt werden (vgl. Abschnitt 14.7.1).
• Für die die Produkte der Files-Fabrikmethoden newOutputStream() und
newInputStream() ist die simultane Nutzung durch mehrere Threads erlaubt.
Abschnitt 14.8 Empfehlungen zur Ein- und Ausgabe 755

14.7.4 MIME-Type einer Datei ermitteln


Von der statischen Files-Methode probeContentType() erhält man eine Information über den MI-
ME-Typ des Dateiinhalts:
public static String probeContentType(Path path) throws IOException
Ursprünglich zur Beschreibung von Mail-Erweiterungen gedacht (Multipurpose Internet Mail Ex-
tensions), wird das MIME-Schema mittlerweile recht universell zur Deklaration von digitalen Inhal-
ten verwendet.
Im folgenden Programm
import java.io.IOException;
import java.nio.file.*;

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.

14.7.5 Stream<String> mit den Zeilen einer Textdatei erstellen


Die statische Methode lines() der Klasse Files liefert ein Objekt der Klasse Stream<String>, das
die Verarbeitung der Zeilen einer Textdatei erleichtert und beim Lesen die Codierung UTF-8 unter-
stellt. Im folgenden Code-Segment werden mit der Stromoperation count() die Zeilen in einer Datei
gezählt (vgl. Abschnitt 12.2.5.4.3):
Stream<String> sol = Files.lines(Paths.get("U:/Eigene Dateien/ausgabe.txt"));
System.out.println("Anzahl der Zeilen: " + sol.count());
Es ist zu beachten, dass die Klasse Stream<String> zu keiner Datenstrom-Hierarchie im Sinne des
aktuellen Kapitels 14 gehört, sondern einen Strom im Sinn der mit Java 8 eingeführten funktionalen
Programmierung repräsentiert (siehe Abschnitt 12.2).

14.8 Empfehlungen zur Ein- und Ausgabe


Weil die Ein-/Ausgabe - Behandlung in Java durch die Vielzahl der beteiligten Klassen und durch
die Koexistenz von Lösungen aus verschiedenen Java-Entwicklungsstadien etwas unübersichtlich
ist, folgt in diesem Abschnitt eine rezeptartige Beschreibung wichtiger Spezialfälle beim Schreiben
in Dateien bzw. beim Lesen aus Dateien.
756 Kapitel 14 Ein- und Ausgabe über Datenströme

14.8.1 Ausgabe in eine Textdatei


Um Textdaten (Datentypen String, char) oder die Zeichenfolgen-Repräsentationen beliebiger ande-
re Datentypen mit geeigneter toString() - Überschreibung in eine Datei zu schreiben, eignet sich
ein durch die statische Methode newBufferedWriter() der Klasse Files erstellter BufferedWriter
(siehe Abschnitt 14.4.1.6) in Kombination mit einem PrintWriter (siehe Abschnitt 14.4.1.5), der
bequeme Ausgabemethoden bietet (z. B. println(), printf(), format()).

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!");
}
}
}

Über Parameter der statischen Files-Fabrikmethode newBufferedWriter()


public static BufferedWriter newBufferedWriter(Path path,
Charset cs,
OpenOption... options)
throws IOException
wählt man:
• die Ausgabedatei per Path-Objekt (NIO.2 - API, vgl. Abschnitt 14.2.1)
• eine Codierung über den Parameter vom Typ Charset (vgl. Abschnitt 14.4.1.2)
Wählt man eine Überladung ohne diesen Parameter, dann wird die Codierung UTF-8 ver-
wendet.
• Ö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.
Die PrintWriter-Methode printf() (alias format()) ermöglicht eine flexible Formatierung der Aus-
gabe.
Die bei der Konsolenausgabe häufig verwendete Escape-Sequenz \n eignet sich nicht dazu, um ei-
nen Zeilenwechsel in eine Textdatei einzufügen. Stattdessen sollte die Plattform-spezifische Zeilen-
schaltung verwendet werden, die mit dem folgenden Aufruf der statischen System-Methode
getProperty() zu ermitteln ist:
System.getProperty("line.separator")
Abschnitt 14.8 Empfehlungen zur Ein- und Ausgabe 757

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.

14.8.2 Textzeilen einlesen


Um Texte aus einer Datei zu lesen, eignet sich ein durch die statische Methode newBuffered-
Reader() der Klasse Files erstellter BufferedReader (siehe Abschnitt 14.4.2.4), der die Anzahl der
Dateizugriffe reduziert und die bei Dateien mit Zeilenstruktur sehr nützliche Methode readLine()
bietet.

String, Buffered- Bytes Textdatei


char Reader

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);
}
}

Über Parameter der der statischen Files-Fabrikmethode newBufferedReader()


public static BufferedReader newBufferedReader(Path path,
Charset cs)
throws IOException
wählt man:
• die Eingabedatei per Path-Objekt (NIO.2 - API, vgl. Abschnitt 14.2.1)
• die Codierung (siehe Abschnitt 14.4.1.2, Voreinstellung: UTF-8)
Die Datei wird zum Lesen eröffnet.
758 Kapitel 14 Ein- und Ausgabe über Datenströme

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.

14.8.3 Zahlen und andere Tokens aus einer Textdatei lesen


Um Werte primitiver Datentypen und andere separierte Zeichenfolgen (Tokens) aus einer Textdatei
zu lesen, kann man ein Objekt aus der Klasse Scanner im Paket java.util verwenden (siehe Ab-
schnitt 14.5):

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());
}
}
}

Über Parameter des folgenden Scanner-Konstruktors


public Scanner(Path source,
String charsetName)
throws IOException
wählt man:
• die Eingabedatei per Path-Objekt (NIO.2 - API, vgl. Abschnitt 14.2.1)
• die Codierung (siehe Abschnitt 14.4.1.2, Voreinstellung: UTF-8)
Abschnitt 14.8 Empfehlungen zur Ein- und Ausgabe 759

14.8.4 Eingabe von der Konsole


Im Abschnitt 14.5 wird beschrieben, wie man Tastatureingaben mit Hilfe der Klasse Scanner ent-
gegennimmt:

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();

14.8.5 Werte mit primitiven Datentypen in eine Binärdatei schreiben


Um Werte mit primitiven Datentypen (z. B. int, double) binär in eine Datei zu schreiben, verwen-
det man ein Filterobjekt aus der Klasse DataOutputStream in Kombination mit einem Ausgabeob-
jekt aus der OutputStream-Hierarchie (siehe Abschnitt 14.3.1.4):

prim. Datentypen DataOutput- Output- Bytes Binärdatei


Stream Stream

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.

14.8.6 Werte mit primitiven Datentypen aus einer Binärdatei lesen


Um Werte mit primitiven Datentypen (z. B. int, double) aus einer Binärdatei zu lesen, verwendet
man ein Filterobjekt aus der Klasse DataInputStream in Kombination mit einem Eingabeobjekt
aus der InputStream - Hierarchie:

prim. DataInput- Input-


Bytes Binärdatei
Typen Stream Stream

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

prim. DataInput- Buffered- Input-


Input- Bytes Binärdatei
Typen Stream Stream
Stream

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))) {
. . .
}

14.8.7 Binäre Objekt(de)serialisierung


Um trotz der im Abschnitt 14.6.1 beschriebenen Sicherheitsbedenken mit Hilfe der traditionellen
Java-Technik zum binären (De)serialisieren Objekte in eine Datei zu schreiben bzw. aus einer zu
Datei lesen, verwendet man die im Abschnitt 14.6 vorgestellten Klassen ObjectOutputStream
bzw. ObjectInputStream. Hier ist die Ausgabe zu sehen:

ObjectOutput- Output-
Objekte Bytes Binärdatei
Stream Stream

Beispiel:
try (ObjectOutputStream oos = new ObjectOutputStream(
new FileOutputStream("test.ser"))) {
oos.writeObject(kunde);
}

Mit der folgenden Datenstromkonstruktion holt man Objekte zurück:

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.

14.9 Übungsaufgaben zum Kapitel 14


1) Welche der folgenden Aussagen sind richtig bzw. falsch?
1. Die Klasse PrintStream spielt in aktuellen Java-Programmen keine Rolle mehr.
2. Ein geschlossener Datenstrom kann anschließend nicht mehr zur Ein- bzw. Ausgabe ver-
wendet werden.
3. Die PrintWriter-Methoden werfen im Zusammenhang mit Ein-/Ausgabe - Problemen keine
IOException-Objekte, sondern sie setzen ein Fehlersignal, das mit der Methode
checkError() abgefragt werden kann.
4. Bei der Textausgabe verwendet ein durch die Files-Fabrikmethode newBufferedWriter()
erstelltes Datenstromobjekt per Voreinstellung die Codierung UTF-8.

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

long time, start = System.currentTimeMillis();


for (byte i = 1; i <= 10; i++) {
time = start + i*1000;
while (System.currentTimeMillis() < time);
bos.write(i + 47);
System.out.print('\u0007');
}
Im Lösungsvorschlag wird das Steuerzeichen \u0007 über den unabhängig von der bos-fos - Kon-
struktion verwendbaren Standardausgabestrom an die Konsole gesendet,
System.out.print('\u0007');
um per Ton die Ankunft eines Bytes im Puffer zu melden. Damit in einem Konsolenprogramm un-
ter Windows von dieser Anweisung tatsächlich etwas zu hören ist, muss (über Systemsteuerung
> Anzeige = Kleine Symbole > Sound) ein geeignetes Soundschema aktiviert sein, z. B.:

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.:
01234567

Rest aus dem Puffer:


89
Weil sich beim Programmende noch zwei Bytes im Puffer befinden, müssen diese per flush() oder
close() vor dem Untergang bewahrt werden.
764 Kapitel 14 Ein- und Ausgabe über Datenströme

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

Beim Multithreading ist allerdings eine sorgfältige Einsatzplanung erforderlich, denn:


• Das Erstellen, Terminieren, Blockieren und Reaktivieren von Threads ist zeitaufwändig, so-
dass der Zeitaufwand für eine eher kleine Aufgabe durch das Multithreading sogar steigen
kann.
• Threads belegen Speicher für ihren individuellen Stack (z. B. per Voreinstellung 1 MB bei
einer 64-Bit - JVM unter Windows), sodass ihre Zahl nicht zu groß werden sollte.1
Während jeder Prozess einen eigenen Adressraum besitzt, laufen die Threads eines Programms im
selben Adressraum ab, sodass sie gelegentlich auch als leichtgewichtige Prozesse bezeichnet wer-
den. Sie verwenden einen gemeinsamen Heap-Speicher, wobei aber jeder Thread als selbständiger
Kontrollfluss bzw. Ausführungsfaden einen eigenen Stack-Speicher benötigt.
In Java ist das Multithreading seit der ersten Version in Sprache, Standardbibliothek und Laufzeit-
umgebung integriert, und in jeder Java-Anwendung sind mehrere Threads aktiv. So läuft z. B. der
Garbage Collector stets in einem eigenen Thread.
Bei der ursprünglichen Multithreading-Lösung in Java müssen die Threads allerdings vom Anwen-
dungsentwickler direkt verwaltet werden, was Aufwand und Fehlerrisiken mit sich bringt. Das gilt
vor allem dann, wenn mehrere Threads auf gemeinsame, variable Datenbestände zugreifen, sodass
eine Synchronisation der Threads erforderlich ist (siehe Abschnitt 15.2). Hier kommt es oft zu Feh-
lern, wobei ein Programm ...
• entweder hängt (Deadlock, siehe Abschnitt 15.3.4)
• oder fehlerhafte Ergebnisse produziert, was noch weit gravierender ist.
Eine fehlerhafte Thread-Synchronisation ist zudem aufgrund variabler Folgen schwer zu analysie-
ren.
Im Laufe der Java-Evolution wurden unterschiedliche Programmiermuster für ein leichter handhab-
bares und performantes Multithreading entwickelt, wobei seit Java 5 (alias 1.5) ein sogenannter
Threadpool zum Einsatz kommt. In diesem Pool befinden sich Threads in einer zur Hardware (zur
Anzahl der logischen CPU-Kerne) passenden Zahl, die nach Erledigung einer Aufgabe nicht been-
det werden, sondern auf neue Aufgaben warten. Statt für eine konkrete Aufgabe (z. B. Bedienung
eines Webzugriffs) jeweils einen neuen Thread zeitaufwändig zu erzeugen und anschließend wieder
zu zerstören, werden eingehende Aufgaben einem freien Pool-Thread zugeteilt oder in eine Warte-
schlange gestellt.
Seit Java 8 sind Multithreading-Lösungen mit weiterentwickelten Abstraktionen verfügbar, die im
Hintergrund einen automatisiert verwalteten Threadpool verwenden:
• Das sogenannte Fork-Join - Framework eignet sich zur parallelen Verarbeitung von gleich-
artigen Elementen einer großen Datenmenge (siehe Abschnitt 15.5). Es kommt auch bei der
parallelen Stromverarbeitung zum Einsatz. Hier geht es um die beschleunigte Erledigung ei-
ner Aufgabe durch die Zerlegung in simultan ausführbare Teilaufgaben (Datenparallelität).
• Soll ein aus unterschiedlichen Aufgaben bestehender Arbeitsablauf durch Parallelisierung
beschleunigt werden, dann eignet sich die Klasse CompletableFuture<T> (siehe Abschnitt
15.6). Hier geht es um die quasi-gleichzeitige Ausführung mehrerer Aufgaben unter Beach-
tung von Abhängigkeitsbeziehungen zwischen den Aufgaben (Aufgabenparallelität).
Wir erarbeiten uns in diesem Kapitel zunächst ein Multithreading-Basiswissen durch den Einsatz
von dedizierten, für einen bestimmten Zweck vom Programmierer erstellten Threads, und bewegen
uns dann auf die aktuellen Multithreading-Frameworks zu.

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).

15.1 Start und Ende eines Threads


Das direkte Erzeugen von Threads über Objekte aus der Klasse Thread wird zunehmend abgelöst
durch die Nutzung von Frameworks, die im Hintergrund mit Multithreading-Techniken arbeiten.
Allerdings erleichtert es der traditionelle Direktkontakt mit Threads, grundlegende Eigenschaften
der Technik kennenzulernen. Wir verwenden ein Beispiel mit Produzenten-Konsumenten - Struktur,
um (im Abschnitt 15.1) den Start und das Ende eines Threads sowie (im Abschnitt 15.2) die Koor-
dination von zwei Threads zu veranschaulichen.

15.1.1 Die Klasse Thread


Ein Thread wird in Java durch ein Objekt aus der Klasse Thread oder aus einer abgeleiteten Klasse
realisiert. Im ersten Beispiel werden die Klassen ProThread und KonThread aus der Klasse
Thread abgeleitet. Sie sollen einen Produzenten bzw. einen Konsumenten modellieren, die alternie-
rend auf einen gemeinsamen Lagerbestand einwirken, der von einem Objekt der Klasse Lager ge-
hütet wird. Wir betrachten zunächst die (nicht von Thread abstammende) Klasse Lager:
class Lager {
private static final int MANZ = 20;
private int bilanz;
private int anz;

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;
}
}

private String formZeit() {


return java.text.DateFormat.getTimeInstance().format(new java.util.Date());
}

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);
}

void liefere(int sub) {


bilanz -= sub;
anz++;
System.out.println("Nr. " + anz + ":\t" + Thread.currentThread().getName()+
" entnimmt\t" + sub + "\tum " + formZeit() + " Uhr. Stand: " + bilanz);
}
}
768 Kapitel 15 Multithreading

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()

Thread Produzent Thread main Thread Konsument

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)

Nr. 1: Produzent ergänzt 72 um 12:43:33 Uhr. Stand: 74


Nr. 2: Konsument entnimmt 98 um 12:43:33 Uhr. Stand: 74
Nr. 3: Konsument entnimmt 31 um 12:43:35 Uhr. Stand: 43
Nr. 4: Produzent ergänzt 32 um 12:43:37 Uhr. Stand: 75
Nr. 5: Konsument entnimmt 42 um 12:43:38 Uhr. Stand: 33
Nr. 6: Produzent ergänzt 44 um 12:43:39 Uhr. Stand: 77
Nr. 7: Konsument entnimmt 63 um 12:43:41 Uhr. Stand: 14
Nr. 8: Produzent ergänzt 42 um 12:43:42 Uhr. Stand: 56
Nr. 9: Konsument entnimmt 99 um 12:43:43 Uhr. Stand: -43
Nr. 10: Produzent ergänzt 77 um 12:43:44 Uhr. Stand: 34
Nr. 11: Konsument entnimmt 13 um 12:43:44 Uhr. Stand: 21
Nr. 12: Konsument entnimmt 83 um 12:43:47 Uhr. Stand: -62
Nr. 13: Produzent ergänzt 90 um 12:43:47 Uhr. Stand: 28
Nr. 14: Produzent ergänzt 47 um 12:43:48 Uhr. Stand: 75
Nr. 15: Konsument entnimmt 101 um 12:43:51 Uhr. Stand: -26
Nr. 16: Produzent ergänzt 42 um 12:43:51 Uhr. Stand: 16
Nr. 17: Konsument entnimmt 79 um 12:43:52 Uhr. Stand: -63
Nr. 18: Produzent ergänzt 22 um 12:43:53 Uhr. Stand: -41
Nr. 19: Konsument entnimmt 90 um 12:43:56 Uhr. Stand: -131
Nr. 20: Produzent ergänzt 54 um 12:43:57 Uhr. Stand: -77

Lieber Konsument, es ist Feierabend!

Lieber Produzent, es ist Feierabend!


U. a. fällt negativ auf:
• Im ersten Protokolleintrag wird berichtet, dass vom Startwert 100 ausgehend eine Ergän-
zung von 72 Einheiten zu einem Bestand von 74 Einheiten geführt habe.
• Der zweite Eintrag behauptet, dass die Entnahme von 98 Einheiten ohne Effekt auf den La-
gerbestand geblieben sei.
• Zwischenzeitlich wird der Bestand mehrmals negativ, was in einem realen Lager nicht pas-
sieren kann.
Ansonsten zeigt die Verzahnung der beiden Threads keine ausgeprägte Regelmäßigkeit, sondern
demonstriert den Indeterminismus bei einem Multithreading - Programmablauf.
Im Abschnitt 15.2 werden Techniken zur Koordination bzw. Synchronisation von Threads vorge-
stellt, mit denen man fehlerhafte Anzeigen und Schlimmeres verhindern kann.
772 Kapitel 15 Multithreading

15.1.2 Das Interface Runnable


Als Basis für einen eigenständigen Kontrollfluss haben wir bisher eine Thread-Ableitung definiert
und die geerbte run() - Methode überschrieben. In Java sind aber auch andere Klassen Thread-
fähig, sofern sie das Interface Runnable implementieren. Dieses Interface verlangt von implemen-
tierenden Klassen eine Instanzmethode ...
• namens run()
• ohne Parameter
• mit dem Rückgabetyp void,
• die keine geprüften (deklarationspflichtigen) Ausnahmen wirft.
Durch das Interface Runnable können Ableitungen beliebiger Basisklassen die Thread-Fähigkeit
erwerben, was für die Flexibilität der Programmierung unverzichtbar ist.
Wir verwenden weiterhin das Produzent-Lager-Konsument - Beispiel aus dem Abschnitt 15.1.1,
ersetzen aber die Thread-Ableitung ProThread
class ProThread extends Thread {
. . .
}
durch die Klasse Produzent, die das Interface Runnable implementiert:
class Produzent implements Runnable {
private Lager lager;

Produzent(Lager lager) {
this.lager = lager;
}

public void run() {


while (lager. istOffen()) {
lager.ergaenze((int) (5 + Math.random()*100));
try {
Thread.sleep((int) (1000 + Math.random()*3000));
} catch(InterruptedException ie) {Thread.currentThread().interrupt();}
}
}
}
Solange die Klasse Produzent keine spezielle Basisklasse erweitert, bleibt der potentielle Vorteil
der Runnable-Konstruktion im Beispiel allerdings ungenutzt.
Im Rumpf der Produzent-Definition sind im Vergleich zur ProThread-Lösung nur zwei Ände-
rungen erforderlich:
• Im Konstruktor der Klasse Produzent kann der Produzenten-Thread keinen Namen erhal-
ten. Eine alternative Möglichkeit zur Benennung wird gleich vorgestellt.
• In der Methode run() muss im catch-Block zur Behandlung der InterruptedException der
aktive Thread, an den der interrupt() - Appell zu richten ist (zur Begründung siehe Seite
769), mit Hilfe der statischen Thread-Methode currentThread() ermittelt werden, weil die
Klasse des handelnden Objekts keine Thread-Ableitung ist, also die interrupt() - Methode
nicht in ihrem Handlungsrepertoire hat.
Auch wenn die in einem neuen Thread auszuführende run() - Methode zu einer beliebigen, das In-
terface Runnable implementierenden Klasse gehört, wird zum Erzeugen des Ausführungsfadens
doch ein Thread-Objekt benötigt, wobei man der passenden Konstruktor-Überladung einen Aktu-
alparameter vom Typ Runnable übergibt:
Abschnitt 15.2 Threads koordinieren 773

• public Thread(Runnable target)


• public Thread(Runnable target, String name)
Optional kann man zusätzlich den Namen des neuen Threads festlegen. Dies geschieht in der Start-
klasse des aktualisierten Beispiels, wo im Vergleich zur vorherigen Lösung nur eine einzige Zeile
zu ändern ist:
class ProKonDemo {
public static void main(String[] args) {
Lager lager = new Lager(100);
Thread pt = new Thread(new Produzent(lager), "Produzent");
KonThread kt = new KonThread(lager);
pt.start();
kt.start();
}
}

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.

15.2 Threads koordinieren


In diesem Abschnitt werden Techniken zur Koordination von einzelnen, explizit erstellten Threads
vorgestellt. Obwohl Java moderne Frameworks bietet, um die aufwändige und fehleranfällige ma-
nuelle Erstellung und Verwaltung von Threads zu reduzieren und gleichzeitig das Potential der
Multithreading-Technik besser auszuschöpfen (siehe Abschnitte 15.4, 15.5 und 15.6), sind die im
aktuellen Abschnitt vorgestellten Begriffe und Verfahren doch weiterhin relevant:
• Es gibt individuelle Problemstellungen, die die Anwendungsvoraussetzungen der
Frameworks nicht erfüllen.
• Für die erfolgreiche Entwicklung von Multithreading-Anwendungen ist ein Verständnis
der involvierten Begriffe und Verfahren sehr nützlich.
Die Koordination von Threads ist vor allem dann anspruchsvoll und fehleranfällig, wenn die
Threads auf gemeinsame, variable Datenbestände zugreifen. Aufgrund einer fehlerhaften Thread-
Koordination kann ein Programm ...
• außer Kontrolle geraten (z. B. in einen Deadlock, siehe Abschnitt 15.3.4)
• oder fehlerhafte Ergebnisse produzieren, was in der Regel noch gravierender ist.
Eine fehlerhafte Thread-Koordination manifestiert sich oft unregelmäßig (z. B. bei einer Race Con-
dition, siehe Abschnitt 15.2.1.1), was die Analyse erschwert.
Von Problemen beim Multithreading bleibt man weitgehend verschont, wenn die Threads entweder
gar keine oder nur unveränderliche Daten teilen.

15.2.1 Fehlerhafte oder veraltete Daten


Wenn mehrere Threads schreibend und/oder lesend auf gemeinsame Daten zugreifen (z. B. auf In-
stanzvariablen von Objekten auf dem Heap), dann können Daten beschädigt werden, oder ein
Thread kann durch die Verwendung von veralteten Daten ein fehlerhaftes Verhalten zeigen.

15.2.1.1 Fehlerhafte Daten aufgrund von nicht-atomaren Operationen


Am Anfang des im Abschnitt 15.1.1 wiedergegebenen Ablaufprotokolls zum Produzenten-
Konsumenten - Beispiel stehen zwei „wirre“ Einträge, die folgendermaßen durch eine so genannte
Race Condition zu erklären sind:
774 Kapitel 15 Multithreading

• Der (zuerst gestartete) Produzenten-Thread nimmt nach einer erfolgreichen istOffen() -


Anfrage die Methode ergaenze() in Angriff und führt die Anweisung
bilanz += add;
aus, was zur Zwischenbilanz von 172 führt.
• Dann muss der Produzent seine Arbeit unterbrechen, weil der Konsumenten-Thread akti-
viert, d.h. vom Zustand ready in den Zustand running befördert wird.
• Mit seiner Anforderung von 98 Einheiten bringt der Konsument in der Methode liefere()
die Lagerbilanz von 172 auf 74.
• Nach dem nächsten Thread-Wechsel macht der Produzent mit seiner Protokollausgabe wei-
ter, wobei aber der aktuelle bilanz-Wert (unter Berücksichtigung der zwischenzeitlichen
Konsumenten-Aktivität) erscheint.
• Schließlich vervollständigt der Konsumenten-Thread seine Meldung.
Es kann nicht nur zu wirren Protokolleinträgen kommen, sondern auch zu einem fehlerhaften
bilanz-Wert. Scheinbar einschrittige Operationen wie die folgende Anweisung in der vom Produ-
zenten-Thread aufgerufenen Methode ergaenze()
bilanz += add;
haben in einen Rechner mehrere Teilschritte zur Folge, sind also nicht atomar (also nicht geschützt
vor Unterbrechungen durch andere Threads), z. B.:
• aktuellen bilanz-Wert aus dem Hauptspeicher in ein CPU-Register einlesen
• Wert (der lokalen Kopie!) erhöhen
• Neuen Wert in den Hauptspeicher schreiben
In der vom Konsumenten-Thread aufgerufenen Methode liefere() führt die Anweisung
bilanz -= sub;
analog zu folgenden Teilschritten:
• aktuellen bilanz-Wert aus dem Hauptspeicher in ein CPU-Register einlesen
• Wert (der lokalen Kopie!) reduzieren
• Neuen Wert in den Hauptspeicher schreiben
Durch unglückliche Thread-Wechsel kann es z. B. zu folgender Sequenz kommen:
• Der Produzent liest den Wert 100.
• Der Konsument liest den Wert 100.
• Der Produzent erhöht seine bilanz-Kopie um 10 auf 110 und schreibt das Ergebnis in den
Hauptspeicher.
• Der Konsument reduziert seine bilanz-Kopie um 10 auf 90 und schreibt das Ergebnis in
den Hauptspeicher. Damit ist der Beitrag des Produzenten verloren gegangen.
Es kann sogar passieren, dass ein Thread beim Schreiben oder Schreiben eines long- oder double-
Werts (64 Bit groß) unterbrochen wird, und dass schlussendlich die 64 Bits einer Variablen von
zwei verschiedenen Threads stammen (Gosling et al. 2021, Abschnitt 17.7; siehe auch Abschnitt
15.2.6.1). Beim Schreiben einer Variablen mit maximal vier Bytes Speicherbedarf kann es nicht
passieren, dass ein Thread unterbrochenen wird und somit ein irregulärer Wert entsteht. Wie der
nächste Abschnitt zeigt, ist aber auch bei solchen Variablen eine Synchronisation erforderlich, wenn
sie von mehreren Threads verwendet werden.
Abschnitt 15.2 Threads koordinieren 775

15.2.1.2 Veraltete Daten im lokalen Cache eines Threads


Bei einer korrekten Multithreading-Programmierung geht es nicht nur darum, Schreibzugriffe meh-
rerer Threads auf gemeinsame Daten zu koordinieren. Auch wenn nur ein einziger Thread auf einen
Datenbestand schreibend zugreift, kann es zu Multithreading-Fehlern kommen, wenn andere
Threads die Daten lesen und auf aktuelle (korrekte) Werte angewiesen sind. Diese von manchen
Programmierern unerwartete Problematik resultiert aus den in modernen Computer-Architekturen
realisierten Hauptspeichertechniken mit (mehrstufigen) Cache-Strategien. Über den verschiedenen
Hardware-Architekturen liegt das Java-Speichermodell das für jeden Thread einen lokalen Cache-
Speicher verwendet:
• Zwar teilen sich grundsätzlich alle Threads denselben Heap-Speicher, doch verwendet jeder
Thread zur Beschleunigung seiner Speicherzugriffe einen lokalen Cache, der sich physisch
z. B. in der Nähe des verwendeten CPU-Kerns befindet.
• Beim Start eines Threads werden alle für ihn relevanten Daten in seinen lokalen Cache ko-
piert (Memory Refresh).
• Es ist garantiert, dass beim Beenden eines Threads der Inhalt seines lokalen Cache-
Speichers in den Hauptspeicher zurückgeschrieben wird (Memory Flush).
Weitere Details zum Java-Speichermodell finden sich z. B. in Gosling et al. (2021, Abschnitt 17.4)
sowie bei Kreft & Langer (2008a).
Wenn mehrere Threads einen inkonsistenten Blick auf dieselben Daten haben, spricht man von ei-
nem Speicherkonsistenzfehler (engl.: memory consistency error). Zum Glück verhindern die im
weiteren Verlauf von Abschnitt 15.2 behandelten Techniken zur Thread-Koordination nicht nur,
dass ein Thread in einem ungünstigen Moment unterbrochen wird, sondern sie sorgen auch für
Speicherkonsistenz. So wird z. B. ein Memory Flush durchgeführt, sobald ein Thread einen ge-
schützten Code-Bereich verlassen hat (siehe Abschnitt 15.2.2.1). Somit ist garantiert, dass kritische
Wertänderungen für andere Threads sofort sichtbar werden.
Neben den Techniken zur Thread-Koordination ist auch die final-Deklaration von Instanzvariablen
relevant für die Sichtbarkeit von Daten für andere Threads (siehe Kreft & Langer 2008a):
• Nach der Ausführung eines Konstruktors kommt es zu einem partiellen Memory Flush, wo-
bei die als final deklarierten Variablen und alle über final deklarierte Referenzen erreichba-
ren Objekte in den allgemeinen Hauptspeicher zurückgeschrieben werden.
• Beim ersten Lesezugriff auf eine finalisierte Instanzvariable kommt es zu einem partiellen
Memory Refresh, wobei der Wert der Variablen und im Fall einer Referenz auch erreichbare
Objekte aus dem allgemeinen Hauptspeicher in den lokalen Cache des lesenden Threads
übertragen werden.

15.2.2 Per Monitor synchronisierte Code-Bereiche


Damit es im Produzent-Lager-Konsument - Beispiel nicht zu einem fehlerhaften Lagerverlaufspro-
tokoll kommt (vgl. Abschnitt 15.1.1), muss offenbar ...
• verhindert werden, dass zwei Threads simultan auf das Lager zugreifen,
• dafür gesorgt werden, dass jeder Thread bei seinen Operationen mit geteilten Daten die ak-
tuellen, eventuell durch den jeweils anderen Thread veränderten Werte kennt.
Java bietet mehrere Techniken, um eine derartige Thread-Koordination zu realisieren, von denen im
weiteren Verlauf von Abschnitt 15.2 ohne Anspruch auf Vollständigkeit beschrieben werden:
776 Kapitel 15 Multithreading

• 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.

15.2.2.1 Synchronisierte Methoden und Blöcke


Bei vielen Aufgabenstellungen ist eine angemessene (z. B. hinreichend performante) Thread-
Koordination mit dem von Java seit der ersten Version unterstützten Monitor-Konzept leicht zu
realisieren. Zu einem Monitor kann jedes Objekt werden, wenn mindestens eine seiner Methoden
mit dem Modifikator synchronized dekoriert ist.
Sobald ein Thread eine als synchronized deklarierte Methode eines noch freien Monitors aufruft,
wird er zum Besitzer dieses Monitors. Man kann sich vorstellen, dass er den einzigen Schlüssel zum
Überwachungsbereich des Monitors (= Menge der synchronisierten Methoden) an sich nimmt. In
der englischen Literatur wird der Vorgang als obtaining the lock beschrieben. Versucht ein anderer
Thread, eine der synchronisierten Methoden desselben Monitors aufzurufen, wird er in den Warte-
zustand versetzt (waiting for monitor, vgl. Abschnitt 15.3.3.2). Sobald der Monitor-Besitzer die
synchronized-Methode beendet, kann ein wartender Thread den Monitor übernehmen und seine
Arbeit fortsetzen. Die Freigabe erfolgt auch dann zuverlässig, wenn die synchronized-Methode mit
einer unbehandelten Ausnahme endet.
Die Synchronisation per Monitor klappt auch bei statischen Methoden, wobei dasjenige Objekt die
Monitorrolle übernimmt, das die Klasse in der JVM repräsentiert (siehe Abschnitt 15.3.4).
Bei einem Konstruktor ist der Modifikator synchronized nicht erlaubt. Weil also ein Konstruktor
nicht per Monitor geschützt werden kann, sollte man in einem Konstruktor keine Referenz zum
entstehenden Objekt veröffentlichen (z. B. über eine statische Variable). Ansonsten könnte parallel
zum noch aktiven Konstruktor ein anderer Thread auf die Instanzvariablen des entstehenden Ob-
jekts zugreifen und einen inkonsistenten Zustand bewirken.
In unserem Produzent-Lager-Konsument - Beispiel müssen die Lager-Methoden istOffen(),
ergaenze() und liefere() als synchronized deklariert werden, weil sie auf mindestens eine
von den beiden kritischen Variablen bilanz und anz lesend und/oder schreibend zugreifen, 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);
}
Nun unterbleiben die wirren Protokolleinträge, doch Ausflüge in negative Lagerzustände sind wei-
ter möglich, z. B.:
Abschnitt 15.2 Threads koordinieren 777

Der Laden ist offen (Bestand = 100)

Nr. 1: Produzent ergänzt 54 um 14:54:31 Uhr. Stand: 154


Nr. 2: Konsument entnimmt 68 um 14:54:31 Uhr. Stand: 86
Nr. 3: Konsument entnimmt 26 um 14:54:33 Uhr. Stand: 60
Nr. 4: Produzent ergänzt 58 um 14:54:34 Uhr. Stand: 118
Nr. 5: Konsument entnimmt 70 um 14:54:35 Uhr. Stand: 48
Nr. 6: Produzent ergänzt 13 um 14:54:35 Uhr. Stand: 61
Nr. 7: Konsument entnimmt 74 um 14:54:38 Uhr. Stand: -13
Nr. 8: Produzent ergänzt 11 um 14:54:38 Uhr. Stand: -2
Nr. 9: Konsument entnimmt 65 um 14:54:40 Uhr. Stand: -67
Nr. 10: Produzent ergänzt 26 um 14:54:41 Uhr. Stand: -41
Nr. 11: Konsument entnimmt 71 um 14:54:42 Uhr. Stand: -112
Nr. 12: Produzent ergänzt 9 um 14:54:43 Uhr. Stand: -103
Nr. 13: Konsument entnimmt 8 um 14:54:45 Uhr. Stand: -111
Nr. 14: Produzent ergänzt 100 um 14:54:46 Uhr. Stand: -11
Nr. 15: Konsument entnimmt 5 um 14:54:47 Uhr. Stand: -16
Nr. 16: Produzent ergänzt 43 um 14:54:48 Uhr. Stand: 27
Nr. 17: Konsument entnimmt 44 um 14:54:51 Uhr. Stand: -17
Nr. 18: Produzent ergänzt 68 um 14:54:51 Uhr. Stand: 51
Nr. 19: Konsument entnimmt 97 um 14:54:53 Uhr. Stand: -46
Nr. 20: Konsument entnimmt 73 um 14:54:54 Uhr. Stand: -119

Lieber Produzent, es ist Feierabend!

Lieber Konsument, es ist Feierabend!

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.

15.2.2.2 Koordination per wait(), notify() und notifyAll()


Mit Hilfe der Object-Methoden wait() und notify() können im Produzent-Lager-Konsument - Bei-
spiel negative Lagerbestände verhindert werden: Trifft eine Konsumenten-Anfrage auf einen unzu-
reichenden Lagerbestand, dann wird der Konsumenten-Thread mit der Methode wait() in den Zu-
stand waiting versetzt (vgl. Abschnitt 15.3.3.2). Die Methode wait() kann nur in einem synchroni-
sierten Bereich, aufgerufen werden, z. B.:
synchronized void liefere(int sub) {
nachfrage = true;
while (bilanz < sub)
try {
System.out.println(Thread.currentThread().getName() +
" muss warten: Keine " + sub + " Einheiten vorhanden.");
wait();
} catch (InterruptedException ie) {
System.err.println(ie);
}

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)

Nr. 1: Produzent ergänzt 29 um 05:23:44 Uhr. Stand: 129


Nr. 2: Konsument entnimmt 10 um 05:23:44 Uhr. Stand: 119
Nr. 3: Produzent ergänzt 48 um 05:23:46 Uhr. Stand: 167
Nr. 4: Produzent ergänzt 53 um 05:23:48 Uhr. Stand: 220
Nr. 5: Konsument entnimmt 23 um 05:23:48 Uhr. Stand: 197
Nr. 6: Konsument entnimmt 28 um 05:23:50 Uhr. Stand: 169
Nr. 7: Produzent ergänzt 104 um 05:23:51 Uhr. Stand: 273
Nr. 8: Konsument entnimmt 80 um 05:23:53 Uhr. Stand: 193
Nr. 9: Produzent ergänzt 14 um 05:23:53 Uhr. Stand: 207
Nr. 10: Produzent ergänzt 79 um 05:23:54 Uhr. Stand: 286
Nr. 11: Konsument entnimmt 49 um 05:23:55 Uhr. Stand: 237
Nr. 12: Konsument entnimmt 85 um 05:23:56 Uhr. Stand: 152
Nr. 13: Produzent ergänzt 13 um 05:23:58 Uhr. Stand: 165
Nr. 14: Konsument entnimmt 56 um 05:23:59 Uhr. Stand: 109
Nr. 15: Produzent ergänzt 52 um 05:23:59 Uhr. Stand: 161
Nr. 16: Produzent ergänzt 21 um 05:24:01 Uhr. Stand: 182
Nr. 17: Konsument entnimmt 90 um 05:24:02 Uhr. Stand: 92
780 Kapitel 15 Multithreading

Nr. 18: Produzent ergänzt 16 um 05:24:04 Uhr. Stand: 108


Nr. 19: Konsument entnimmt 100 um 05:24:05 Uhr. Stand: 8
Konsument muss warten: Keine 97 Einheiten vorhanden.
Nr. 20: Produzent ergänzt 21 um 05:24:07 Uhr. Stand: 29
Konsument muss warten: Keine 97 Einheiten vorhanden.
Nr. 21: Produzent ergänzt 50 um 05:24:09 Uhr. Stand: 79
Konsument muss warten: Keine 97 Einheiten vorhanden.
Nr. 22: Produzent ergänzt 98 um 05:24:11 Uhr. Stand: 177
Nr. 23: Konsument entnimmt 97 um 05:24:11 Uhr. Stand: 80

Lieber Produzent, es ist Feierabend!

Lieber Konsument, es ist Feierabend!


Wenn nach der geplanten Anzahl von Arbeitsvorgängen noch eine unversorgte Konsumentenanfor-
derung besteht, dann muss der Lagerist so lange weiterarbeiten, bis der Produzent für einen ausrei-
chenden Lagerbestand gesorgt hat, um den Konsumenten bedienen zu können.
Mit notify() bzw. notifyAll() wird mitgeteilt, dass eine neue Lage eingetreten sei. Ob ein reaktivier-
ter Thread nun die benötigten Voraussetzungen für seine Tätigkeit vorfindet, muss er selbst ent-
scheiden. Endet die Prüfung negativ, muss er erneut wait() aufrufen. Daher sollte wait() stets in
einer Schleife aufgerufen werden, deren Bedingungsteil die kritische Prüfung enthält (siehe oben).
Ein Thread kann per InterruptedException aus dem Wartezustand gerissen werden, weil ihm per
interrupt() aus einem anderen Thread ein Unterbrechungssignal zugestellt wurde. Die Methode
wait() ...
• schaltet in diesem Fall das Unterbrechungssignal ab
• und endet dann mit dem Werfen einer InterruptedException.
Im Beispiel macht die Methode liefere() im catch-Block zur InterruptedException lediglich
eine Protokollausgabe, restauriert aber nicht das von wait() vor dem Werfen der Ausnahme abge-
schaltete Unterbrechungssignal (vgl. die Empfehlung zur InterruptedException-Behandlung auf
Seite 769). In anderen Fällen ist es sinnvoll, im catch-Block zur InterruptedException das Unter-
brechungssignal zu restaurieren und anschließend auf höherer Ebene eine Beendigung des Threads
in Erwägung zu ziehen.
Man könnte das Beispiel noch um eine Absicherung gegen Lagerüberlauf absichern. Wir gehen
jedoch der Einfachheit halber von einem unendlich großen Lager aus.

15.2.3 Explizite Lock-Objekte


Das Interface Lock aus dem Paket java.util.concurrent.locks und die implementierende Klasse
ReentrantLock kommen bei speziellen Aufgabenstellungen als Alternative zu den im Abschnitt
15.2.2 behandelten synchronisierten Methoden und Blöcken in Frage. Man gewinnt an Flexibilität,
muss aber auch mehr Verantwortung übernehmen. Wie Goetz et al. (2006, S. 277) betonen, sind die
expliziten Lock-Objekte kein Ersatz für die synchronisierten Code-Bereiche, sondern eine Ergän-
zung.

15.2.3.1 Interface Lock und Klasse ReentrantLock


Beim Betreten eines synchronisierten Bereichs bestehend aus synchronisierten Methoden und/oder
Blöcken wird vom aktiven Thread ein Monitor (impliziter Lock) erworben und beim Verlassen des
Bereichs automatisch zurückgegeben, wobei die Rückgabe selbst im Ausnahmefall sichergestellt
ist. Demgegenüber ist bei expliziten Lock-Objekten der Gültigkeitsbereich nicht an einen Code-
Bereich gebunden. Ein (z. B. mit der Methode lock() erworbener) expliziter Lock kann in Abhän-
Abschnitt 15.2 Threads koordinieren 781

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;

public static void main(String[] args) {


Produzent prod = new Produzent();
Kunde kunde = new Kunde();
prod.start();
kunde.start();
}
}

class Produzent extends Thread {


Produzent () {
super("Produzent");
}
@Override
public void run() {
while (true) {
System.out.println("Produzent beantragt den Lock.");
Lager.rl.lock();
try {
if (Lager.bestand < 500)
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();}
}
} finally {
System.out.println("Produzent gibt den Lock frei.");
Lager.rl.unlock();
}
try {
Thread.sleep(2000);
} catch(InterruptedException ie) {interrupt();}
}
}
}

class Kunde extends Thread {


Kunde () {
super("Kunde");
}
@Override
public void run() {
while (true) {
System.out.println("Kunde beantragt den Lock.");
Lager.rl.lock();
try {
int sub = (int) (100 + Math.random()*100);
Lager.bestand -= sub;
System.out.println(Thread.currentThread().getName()+
" entnimmt\t" + sub + " Stand: " + Lager.bestand);
} finally {
System.out.println("Kunde gibt den Lock frei");
Lager.rl.unlock();
}
try {Thread.sleep(2000);} catch(InterruptedException ie) {interrupt();}
}
}
}
Abschnitt 15.2 Threads koordinieren 783

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.

15.2.3.2 Koordination per await(), signal() und signalAll()


Mit einem expliziten Lock-Objekt lassen sich über die Methode newCondition() beliebig viele
Condition-Objekte verbinden, und deren Methoden await(), signal() und signalAll() erlauben eine
Verfeinerung der im Abschnitt 15.2.2.2 beschriebenen Thread-Koordinierung per wait(), notify()
und notifyAll().
In einem Beispiel mit zwei Threads, die einen kontinuierlichen Verbraucher bzw. einen bei Bedarf
tätigen Nachfüller simulieren, steht ein Condition-Objekt namens toLow für einen zu niedrigen
Warenbestand und ein Condition-Objekt namens filled für einen aufgefüllten Bestand:
class Lager {
static ReentrantLock rl = new ReentrantLock();
static Condition toLow = rl.newCondition();
static Condition filled = rl.newCondition();
static int bestand = 200;

public static void main(String[] args) {


Produzent prod = new Produzent();
Kunde kunde = new Kunde();
prod.start();
kunde.start();
}
}

Der Nachfüller könnte nach getaner Arbeit ...


• das Lock-Objekt zurückgeben
• und eine Schlafpause einlegen, wobei deren Dauer schlecht an den Arbeitsbedarf anzupas-
sen wäre.
Viel geschickter ist es aber, auf das Eintreten der Bedingung toLow zu warten, die neuen Arbeits-
bedarf signalisiert. Um diesen speziellen Weckauftrag zu formulieren, ruft der Nachfüller die
await() - Methode des Condition-Objekts toLow auf und gibt dabei den Lock automatisch frei:1

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

class Produzent extends Thread {


Produzent () {
super("Produzent");
}

@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 Automatisierte Thread-Koordination für Produzenten-Konsumenten - Konstellationen


Die im bisherigen Verlauf von Abschnitt 15.2 beschriebenen Techniken zur Thread-Koordination
sind flexibel, aber auch fehleranfällig. Für häufig auftretende Aufgaben bietet Java daher Standard-
lösungen zur Vermeidung von Aufwand und Fehlern. In diesem Abschnitt werden zwei Lösungen
vorgestellt, die sich bei einer Produzenten-Konsumenten - Konstellation bewähren:
• Das Interface BlockingQueue<E> mit implementierenden Klassen wie z. B. Array-
BlockingQueue<E> und LinkedBlockingQueue<E>
• Verbindung von zwei Threads per Pipe (dt.: Rohrleitung) über die Datenstromklassen
PipedOutputStream und PipedInputStream

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;

class ProThread extends Thread {


private BlockingQueue<Integer> depot;
private int[] produkte = {1, 2, 3};
Thread konsument;

ProThread(BlockingQueue<Integer> dep, Thread kon) {


depot = dep;
konsument = kon;
}

@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;

class KonThread extends Thread {


private BlockingQueue<Integer> depot;

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

Der Produzent liefert gleich.


Der Konsument hat bezogen: 2
Kurz nach der Lieferung ist der Konsument RUNNABLE
Produzent wacht auf, Konsument ist WAITING

Der Produzent liefert gleich.


Der Konsument hat bezogen: 3
Kurz nach der Lieferung ist der Konsument RUNNABLE
Produzent wacht auf, Konsument ist TERMINATED
zeigen, ...
• dass der Konsumenten-Thread im Zustand RUNNABLE ist, bis die Methode take() auf Da-
tenmangel trifft, was zum Zustand WAITING führt,
• dass sich ein Thread nach dem Ende seine run() - Methode im Zustand TERMINATED be-
findet.
Auf einem Rechner mit abweichender CPU-Leistung müssen eventuell Einstellungen angepasst
werden, damit sich dasselbe Muster zeigt.
Das IntelliJ-Projekt mit dem Beispielprogramm befindet sich im Ordner
...\BspUeb\Multithreading\Threads koordinieren\BlockingQueue

15.2.4.2 PipedOutputStream und PipedInputStream


Durch ein Tandem aus einem PipedOutputStream (Basisklasse: OutputStream, vgl. Abschnitt
14.3.1) und einem verbundenen PipedInputStream (Basisklasse: InputStream, vgl. Abschnitt
14.3.2) lässt sich ein unidirektionaler Datentransfer zwischen zwei Threads einrichten. Ein Thread
schreibt Bytes in den PipedOutputStream und ein anderer Thread kann aus dem verbundenen
PipedInputStream lesen, sodass sich eine Produzenten-Konsumenten - Kooperation realisieren
lässt. Man kann sich vorstellen, dass die beiden Threads mit einer Rohrleitung (engl. pipe) verbun-
den sind.
788 Kapitel 15 Multithreading

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);

KonThread kt = new KonThread(pipedInStream);


ProThread pt = new ProThread(pipedOutStream, kt);
pt.start();
kt.start();
}
}

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 Klassen zur Thread-Synchronisation


Das mit Java 5 (alias 1.5) eingeführte Paket java.util.concurrent enthält einige Klassen zur Reali-
sation von speziellen Mustern der Thread-Koordination.

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

synchronized void liefere(int sub) {


bilanz -= sub;
anz++;
System.out.println("Nr. " + anz + ":\t" + Thread.currentThread().getName() +
" entnimmt\t" + sub + "\tum " + formZeit() + " Uhr. Stand: " + bilanz);
}

In der Lager-Methode ergaenze()


void ergaenze(int nr, int add) throws InterruptedException{
try {
sem.acquire();
anwProd.add(nr);
System.out.println(" " + Thread.currentThread().getName() + " kommt");
for (int i = 0; i < 3; i++) {
synchronized(this) {
bilanz += add;
anz++;
System.out.println("Nr. " + anz + ":\t" + Thread.currentThread().getName() +
" ergänzt\t" + add + "\tum " + formZeit() + " Uhr. Stand: " + bilanz +
" (Prod: " + anwProd + ")");
}
Thread.sleep(500);
}
} finally {
System.out.println(" " + Thread.currentThread().getName() + " geht");
anwProd.remove(nr);
sem.release();
}
}
bewirbt sich ein Produzenten-Thread um einen Passierschein und trägt sich dann in das als synchro-
nisiertes HashSet<Integer> - Objekt (vgl. Abschnitt 10.6.1)
Set<Integer> anwProd = Collections.synchronizedSet(new HashSet<Integer>());
realisierte Verzeichnis der aktiven Produzenten ein:
sem.acquire();
anwProd.add(nr);
Um die simultane Tätigkeit von zwei Produzenten-Threads beobachten zu können, absolviert ein
Produzent bei jedem Lagerbesuch drei Einzellieferungen. Die Protokolleinträge erfolgen weiterhin
in einem synchronisierten Block und enthalten am Ende eine Liste mit den aktuell im Lager befind-
lichen Produzenten. Nach erledigter Arbeit entfernt sich ein Produzent aus dem Verzeichnis der
aktiven Lieferanten und gibt seine Lagerberechtigung zurück:
anwProd.remove(nr);
sem.release();
Die zugehörigen Anweisungen befinden sich im finally-Block einer try-Anweisung, damit sie unter
allen Umständen ausgeführt werden.
Von acquire() ist eine InterruptedException zu erwarten, die entweder per throws-Klausel ange-
meldet oder per catch-Block behandelt werden muss. In der Methode ergaenze() wird die
throws-Klausel gewählt, sodass sich der Aufrufer um die Unterbrechungsaufforderung kümmern
muss. Beim Aufrufer handelt es sich um die run() - Methode des Produzenten-Threads, die keine
throws-Klausel verwenden darf, weil eine Basisklassenmethode ohne throws-Klausel überschrie-
ben wird:
Abschnitt 15.2 Threads koordinieren 791

@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

Lieber Produzent 3, es ist Feierabend!

Lieber Konsument, es ist Feierabend!

Lieber Produzent 2, es ist Feierabend!


Nr. 23: Produzent 5 ergänzt 88 um 14:24:28 Uhr. Stand: 1029 (Prod: [5])

Lieber Produzent 4, es ist Feierabend!


Produzent 5 geht

Lieber Produzent 1, es ist Feierabend!

Lieber Produzent 5, es ist Feierabend!

Das IntelliJ-Projekt mit dem Beispielprogramm befindet sich im Ordner


...\BspUeb\Multithreading\Threads koordinieren\Synchronizer\Semaphore
792 Kapitel 15 Multithreading

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();}
}
}

public static void main(String[] args) throws InterruptedException {


int cdn = 10;
CountDownLatch cdl = new CountDownLatch(cdn);
new Thread(() -> waitAndLiftOff(cdl)).start();
Thread.sleep(300);
System.out.println("\nCountdown läuft:");
for (int i = cdn; i > 0; i--) {
System.out.print(i + " ");
cdl.countDown();
Thread.sleep(500);
}
}
}
Der Raketen-Thread wird über ein per Lambda-Ausdruck realisiertes Runnable-Objekt gestartet.
So kann das CountdownLatch-Objekt an die ausführende Methode des Threads übergeben werden,
obwohl die run() - Methode im Interface Runnable keinen Parameter besitzt.
Ein CountdownLatch-Objekt ist verbraucht und nicht erneut verwendbar, nachdem das Schloss
(engl.: latch) aufgesprungen ist.
In ernsthaften Anwendungen dient ein CountdownLatch-Objekt dazu, einen oder mehrere Threads
warten zu lassen, bis andere Threads bestimmte Operationen ausgeführt haben.
Abschnitt 15.2 Threads koordinieren 793

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];

static final Phaser phaser = new Phaser(N_SAMPLES) {


@Override
protected boolean onAdvance(int phase, int registeredParties) {
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(" + phase + ") = " + se);
return phase == N_EXPERIMENTS - 1;
}
};

1
https://docs.oracle.com/en/java/javase/17/docs/api/java.base/java/util/concurrent/Phaser.html
Abschnitt 15.2 Threads koordinieren 797

static class Meaner implements Runnable {


private int col;
private Random zzg = new Random();
Meaner(int col) {
this.col = col;
}
public void run() {
while (!phaser.isTerminated()) {
double mean = 0.0;
for (int i = 0; i < SAMPLE_SIZE; i++)
mean = mean + zzg.nextDouble();
data[col] = mean / SAMPLE_SIZE;
phaser.arriveAndAwaitAdvance();
}
}
}

public static void main(String[] args) {


for (int i = 0; i < N_SAMPLES; i++)
new Thread(new Meaner(i)).start();
}
}

Im Beispielprogramm stellen die Meaner-Threads ihre Tätigkeit ein, wenn das Phaser-Objekt ter-
miniert ist:
while (!phaser.isTerminated()) {...}

15.2.6 Nicht-blockierende Koordination


Wenn implizite oder explizite Lock-Objekte häufig und nur für kurze Zeit erworben werden, dann
kann der anteilige Aufwand für die Thread-Koordination unverhältnismäßig hoch ausfallen. Weite-
re Nachteile der blockierenden Thread-Koordination sind:
• Threads mit hoher Priorität können durch einen Thread mit niedriger Priorität aufgehalten
werden, der im Besitz eines Locks ist (zu den Prioritäten von Threads siehe Abschnitt
15.3.3.1).
• Wenn die Thread-Koordination nicht nur ineffizient ist, sondern sogar fehlerhaft, dann kann
es zum Programmstillstand aufgrund eines Deadlocks kommen (siehe Abschnitt 15.3.4).
Anschließend werden zwei Verfahren zur nicht-blockierenden Thread-Koordination beschrieben.
Streng genommen sind dabei ebenfalls Sperren im Spiel, die aber durch die Ausnutzung von Hard-
ware-Unterstützung nur geringe Kosten verursachen. Allerdings lassen sich durch die beiden kos-
tengünstigen und einfachen Verfahren nur spezielle Koordinierungsprobleme lösen.

15.2.6.1 Modifikator volatile


Greifen zwei Threads schreibend auf ein Feld vom Typ double oder long zu, dann kann es durch
einen unglücklichen Thread-Wechsel passieren, dass die beiden Threads jeweils 32 Bit zu einem
sinnlosen Speicherwert mit insgesamt 64 Bit beisteuern. Dies wird durch den Modifikator volatile
verhindert, der aus naheliegenden Gründen nur für Felder erlaubt ist, z. B.:
private volatile long counter;
Im Hinblick auf die Sichtbarkeit von gemeinsamen Daten für andere Threads ist von erheblicher
Relevanz, dass ...
• beim Lesen einer als volatile deklarierten Variablen ein Memory Refresh stattfindet, sodass
alle Daten im lokalen Cache des lesenden Threads aktualisiert werden,
• beim Schreiben in eine volatile deklarierten Variable ein Memory Flush stattfindet, sodass
alle Inhalte aus dem lokalen Cache des schreibenden Threads in den allgemeinen Hauptspei-
cher übertragen werden (vgl. Abschnitt 15.2.1.2 zum Java-Speichermodell).
798 Kapitel 15 Multithreading

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).

15.2.6.2 Atomare Variablen


Die seit Java 5 (alias 1.5) verfügbaren Atomic-Klassen (z. B. AtomicBoolean, AtomicInteger,
AtomicLong, AtomicReference) im API-Paket java.util.concurrent.atomic ermöglichen eine
Lock-freie Thread-Koordination beim Zugriff auf eine einzelne Variable. So lässt sich z. B. ein
durch mehrere Threads genutzter Zähler ohne implizites oder explizites Lock-Objekt realisieren.
Viele Atomic-Klassen arbeiten als Wrapper, indem sie einen primitiven Wert in ein Objekt verpa-
cken (z. B. AtomicBoolean, AtomicInteger, AtomicLong). Abweichend von den im Abschnitt 5.3
beschriebenen Wrapper-Klassen (z. B. Boolean, Integer, Long) sind die Atomic-Objekte aller-
dings veränderlich.
Oft werden die Atomic-Objekte als bessere Alternative zu volatile-Feldern betrachtet (siehe z. B.
Goetz 2006, S. 325). Der volatile-Modifikator (siehe Abschnitt 15.2.6.1) sorgt für:
• Atomarität von Wertveränderungen bei 64-Bit - Typen (long, double)
• sowie für die Sichtbarkeit einer Wertveränderung bei beliebigen Typen für alle Threads
Die wichtigen Prä- bzw. Post - Inkrement- bzw. Dekrementoperatoren arbeiten jedoch trotz volati-
le-Dekoration nicht atomar, sodass die simultane Verwendung durch mehrere Threads zu Fehlern
führen kann. Benutzt z. B. eine Klasse die folgende Konstruktion für eine Zählvariable, dann kön-
nen zwei simultane Aufrufe der Methode incrementCounter() durch verschiedene Threads zu
einem lediglich um eins (statt zwei) erhöhten Wert führen:
private volatile int counter;

public void incrementCounter() {


counter++;
}

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

private AtomicInteger counter;

public void incrementCounter() {


counter.incrementAndGet();
}
Wenn es bei einer Thread-Koordination lediglich um das Zählen oder um die Vergabe von eindeu-
tigen Indizes geht, dann bieten die Atomic-Klassen eine sichere und einfache Lösung.
Dabei verwenden die Atomic-Klassen keine von den oben beschriebenen (schwergewichtigen)
Lock-Techniken, sondern sie arbeiten mit der in modernen CPUs vorhandenen Compare-and-Set -
Instruktion (CAS) zur Unterstützung von konkurrierenden Variablenzugriffen. Diese thread-sicher
realisierte Instruktion prüft, ob eine Variable den erwarteten Wert hat, und setzt sie genau dann auf
den gewünschten Wert. In der Klasse AtomicInteger steht die folgende CAS-basierte Methode zur
Verfügung, auf der andere Methoden (siehe oben) aufbauen, um atomare Operationen zu realisie-
ren:1
public final boolean compareAndSet(int expectedValue, int newValue)
Weil die CAS-Basismethode im Wesentlichen mit einem einzigen Maschinenbefehl auskommt,
bieten die darauf aufbauenden Atomic-Klassen thread-sichere Operationen bei minimalen Kosten.
Nach Oechsle (2018, S. 165f) darf man sich die Arbeitsweise der AtomicInteger-Methode
incrementAddGet(), die eine thread-sichere Präinkrement-Operation realisiert, so vorstellen:
public class AtomicInteger {
private volatile int value;

public AtomicInteger(int initialValue) {


value = initialValue;
}
public AtomicInteger() {}

public final boolean compareAndSet(int expectedValue, int newValue) {


. . .
}

public final int incrementAndGet() {


int currentValue, newValue;
do {
currentValue = value;
newValue = currentValue + 1;
} while(!compareAndSet(currentValue, newValue));
return newValue;
}

. . .
}

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.

15.3 Direkt gestartete Threads verwalten


Die in diesem Abschnitt beschriebenen Aufgaben und Probleme sind vor allem beim traditionellen
Multithreading mit direkt gestarteten Threads relevant. Einige Themen (z. B. Thread-Prioritäten,
Deadlocks) sind aber von generellem Interesse für die Multithreading-Programmierung.

15.3.1 Weck mich, wenn Du fertig bist (join)


Wenn ein Thread erst dann weiterarbeiten möchte, wenn ein anderer Thread seine Tätigkeit beendet
hat, kann er diesen mit der Methode join() um entsprechende Benachrichtigung bitten und dann auf
seine Reaktivierung warten.
Ein aus der folgenden Klasse resultierender Thread schreibt fünf Zeilen auf die Konsole:
class Thread1 extends Thread {
@Override
public void run() {
for (int i = 0; i < 5; i++)
System.out.println("Thread 1, i = " + i);
}
}
Bevor ein Thread aus der folgenden Klasse ebenfalls fünf Zeilen schreibt, wartet er auf das Arbeits-
ende eines Kollegen, den er per Konstruktor kennenlernt:
class Thread2 extends Thread {
private Thread1 t1;

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);

15.3.2 Andere Threads unterbrechen, fortsetzen oder abbrechen


In diesem Abschnitt beschäftigen wir uns damit, wie ein anderer Thread unterbrochen und fortge-
setzt oder abgebrochen werden kann. Im Unterschied zu der in den Abschnitten 15.2.2.2 und
15.2.3.2 beschriebenen Thread-Koordination geht die Initiative diesmal nicht vom betroffenen
Thread aus, sondern von einem anderen Thread.

15.3.2.1 Unterbrechen und fortsetzen


Zum Unterbrechen und Reaktivieren eines anderen Threads sollten die früher vorgesehenen
Thread-Methoden suspend() und resume() nicht mehr verwendet werden. Ein suspend() - Aufruf
kann zu einem Deadlock (siehe Abschnitt 15.3.4) führen, weil ein suspendierter Thread die in sei-
nem Besitz befindlichen (impliziten oder expliziten) Lock-Objekte behält.
Stattdessen wird eine Inter-Thread - Kommunikation über die Object-Methoden wait() und notify()
(bei Verwendung von synchronisierten Code-Bereichen) bzw. über die Condition -Methoden a-
wait() und signal() (bei Verwendung von expliziten Lock-Objekten) empfohlen (siehe Abschnitt
15.2.2.2 bzw. 15.2.3.2). Dabei wird dem Ziel-Thread z. B. über eine von ihm regelmäßig zu be-
obachtende Variable signalisiert, dass er sich in den Zustand waiting begeben soll. In seinem Besitz
befindliche Monitore und Lock-Objekte werden dabei automatisch zurückgegeben, sodass kein
802 Kapitel 15 Multithreading

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

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);
if (bilanz < 50 && konsument.isAlive()) {
System.out.println("\n" + konsument.getName() +
" sollte wegen Lager < 50 enden um " + formZeit() + " Uhr. Stand: "+bilanz);
konsument.interrupt();
}
}

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)

Nr. 1: Produzent ergänzt 62 um 22:58:14 Uhr. Stand: 162


Nr. 2: Konsument entnimmt 74 um 22:58:14 Uhr. Stand: 88
Nr. 3: Produzent ergänzt 7 um 22:58:18 Uhr. Stand: 95
Nr. 4: Konsument entnimmt 51 um 22:58:18 Uhr. Stand: 44
Nr. 5: Produzent ergänzt 44 um 22:58:19 Uhr. Stand: 88
Nr. 6: Konsument entnimmt 25 um 22:58:20 Uhr. Stand: 63
Nr. 7: Produzent ergänzt 52 um 22:58:21 Uhr. Stand: 115
Nr. 8: Produzent ergänzt 56 um 22:58:22 Uhr. Stand: 171
Nr. 9: Konsument entnimmt 92 um 22:58:22 Uhr. Stand: 79
Nr. 10: Konsument entnimmt 73 um 22:58:25 Uhr. Stand: 6
Nr. 11: Produzent ergänzt 18 um 22:58:25 Uhr. Stand: 24

Konsument sollte wegen Lager < 50 enden um 22:58:25 Uhr. Stand: 24


Nicht sehr kundenorientiert!

Nr. 12: Produzent ergänzt 6 um 22:58:27 Uhr. Stand: 30


Nr. 13: Produzent ergänzt 37 um 22:58:31 Uhr. Stand: 67
Nr. 14: Produzent ergänzt 26 um 22:58:34 Uhr. Stand: 93
Nr. 15: Produzent ergänzt 99 um 22:58:36 Uhr. Stand: 192
Nr. 16: Produzent ergänzt 38 um 22:58:40 Uhr. Stand: 230
Nr. 17: Produzent ergänzt 35 um 22:58:43 Uhr. Stand: 265
Nr. 18: Produzent ergänzt 42 um 22:58:46 Uhr. Stand: 307
Nr. 19: Produzent ergänzt 14 um 22:58:48 Uhr. Stand: 321
Nr. 20: Produzent ergänzt 45 um 22:58:50 Uhr. Stand: 366

Lieber Produzent, es ist Feierabend!

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.

15.3.3.1 Scheduling und Prioritäten


Den Bestandteil der virtuellen Maschine, der die verfügbare Rechenzeit auf die arbeitswilligen
Threads verteilt, bezeichnet man als Scheduler. Er orientiert sich u. a. an den Prioritäten der
Threads, die in Java Werte von 1 bis 10 annehmen können:
int-Konstante in der Klasse Thread Wert
Thread.MAX_PRIORITY 10
Thread.NORM_PRIORITY 5
Thread.MIN_PRIORITY 1
Es hängt allerdings zum Teil von der Wirtsplattform ab, wie viele Prioritätsstufen wirklich unter-
schieden werden.
Der in einer Java-Anwendung automatisch gestartete Thread main hat die Priorität 5, was man un-
ter Windows in einer Java-Konsolenanwendung über die Tastenkombination Strg+Pause in Er-
fahrung bringen kann, z. B.:
"main" prio=5 tid=0x00035b28 nid=0xd48 runnable [0x0007f000..0x0007fc3c]
Ein Thread überträgt seine aktuelle Priorität auf die bei seiner Ausführung gestarteten Threads,
z. B.:
"Konsument" prio=5 tid=0x00ab5a40 nid=0xa74 waiting on condition [0x0ad0f000..0x0ad0fd68]
. . .
"Produzent" prio=5 tid=0x00ab58c0 nid=0xfb0 waiting on condition [0x0accf000..0x0accf9e8]

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.

15.3.3.2 Zustände von Threads


In der folgenden Abbildung werden für einen explizit kreierten Thread, der nicht von einem
Threadpool (siehe Abschnitt 15.4) verwaltet wird, wichtige Zustände und Anlässe für Zustands-
übergänge dargestellt:

new-Operator

new

start()

ready

• Prozessorberech- Thread erhält


tigung abgelaufen Prozessor-
• notify() • yield() berechtigung
• notifyAll() • join-Thread endet
• signal() • interrupt()
• signalAll running
• interrupt()

• wait() Monitor oder


sleep() run() beendet read() join()
• await() Lock belegt

waiting for waiting for


blocked sleeping terminated blocked for IO
notification Thread-Term.

Monitor oder Lock frei Daten verfügbar

• 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();

public static void main(String[] args) {


(new T1()).start();
(new T2()).start();
}
}

class T1 extends Thread {


@Override
public void run() {
Deadlock.lock1.lock();
System.out.println("Thread 1 besitzt Lock l.");
try {Thread.sleep(100);} catch (Exception e) {interrupt();}
System.out.println("Thread 1 moechte Lock 2 erwerben.");
Deadlock.lock2.lock();
System.out.println("Thread 1 besitzt Lock 2.");
Deadlock.lock2.unlock();
Deadlock.lock1.unlock();
}
}
Abschnitt 15.4 Aufgaben per Threadpool erledigen 807

class T2 extends Thread {


@Override
public void run() {
Deadlock.lock2.lock();
System.out.println("Thread 2 besitzt Lock 2.");
try {Thread.sleep(100);} catch (Exception e) {interrupt();}
System.out.println("Thread 2 moechte Lock 1 erwerben.");
Deadlock.lock1.lock();
System.out.println("Thread 2 besitzt Lock 1.");
Deadlock.lock1.unlock();
Deadlock.lock2.unlock();
}
}
Zwei Threads versuchen jeweils, beide Sperrobjekte zu erwerben:
• Der erste Thread erwirbt lock1, beschäftigt sich ein Weilchen (simuliert per sleep() - Auf-
ruf) und versucht dann, auch noch lock2 zu erwerben.
• Der zweite Thread erwirbt lock2, beschäftigt sich ein Weilchen (simuliert per sleep() -
Aufruf) und versucht dann, auch noch lock1 zu erwerben.
Die beiden Threads sind im ersten Schritt erfolgreich und blockieren sich dann gegenseitig:
Thread 1 besitzt Lock l.
Thread 2 besitzt Lock 2.
Thread 1 moechte Lock 2 erwerben.
Thread 2 moechte Lock 1 erwerben.
Wenn beide Threads in derselben Reihenfolge vorgehen, also z. B. beide zunächst lock1 anstreben
und danach lock2, dann kommen sie zum Erfolg, wobei sich ein Thread etwas gedulden muss,
z. B.:
Thread 1 besitzt Lock l.
Thread 1 moechte Lock 2 erwerben.
Thread 1 besitzt Lock 2.
Thread 2 besitzt Lock l.
Thread 2 moechte Lock 2 erwerben.
Thread 2 besitzt Lock 2.

15.4 Aufgaben per Threadpool erledigen


Um zahlreiche Einzelaufgaben im Parallelbetrieb zu erledigen, muss ein Programm nicht für jeden
Auftrag einen neuen Thread erzeugen und nach der Erledigung wieder abschreiben. Stattdessen
kann ein sogenannter Threadpool beauftragt werden. Neue Aufträge werden auf die verfügbaren
Pool-Threads verteilt. Nach Erledigung eines Auftrags wird ein Thread nicht beendet, sondern er
steht für weitere Aufgaben bereit.

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

es.execute(new KonThread(lager, i));


Über die blockierenden Methoden invokeAny() bzw. invokeAll()
<T> T invokeAny(Collection<? extends Callable<T>> tasks)
throws InterruptedException, ExecutionException
<T> List<Future<T>> invokeAll(Collection<? extends Callable<T>> tasks)
throws InterruptedException
übergibt man eine Kollektion mit Aufgaben an einen ExecutorService und wartet dann, bis irgend-
eine Aufgabe beendet ist bzw. bis alle Aufgaben beendet sind. Man erhält ein Ergebnis zurück, und
die übergebenen Aufgaben müssen das Interface Callable<T> erfüllen, das im Abschnitt 15.4.2
beschrieben wird.
Findet sich für einen unbeschäftigten Thread binnen 60 Sekunden keine neue Verwendung, dann
wird er terminiert und aus dem Pool entfernt, sodass sich der Ressourcenverbrauch stets am Bedarf
orientiert.
Ein nicht mehr benötigter Threadpool sollte explizit terminiert werden, um Ressourcen freizugeben,
wenngleich er nach einiger Zeit ohne Aufgaben automatisch endet. Man kann den ExecutorService
mit shutdown() beauftragen, keine neuen Aufgaben anzunehmen, die anstehenden Aufgaben zu
erledigen und dann seinen Dienst zu quittieren. Die blockierende Methode awaitTermination()
kehrt zurück, wenn ein ExecutorService nach der shutdown() - Aufforderung seinen Dienst been-
det hat.
Wer mehr Kontrolle über die Eigenschaften eines Threadpools benötigt (z. B. maximale Idle-Zeit
eines ruhenden Threads, maximale Anzahl der Pool-Threads), kann ein Objekt der Klasse Thread-
PoolExecutor verwenden. Diese Klasse implementiert ebenfalls das Interface ExecutorService
und bietet Steuerungsoptionen über Konstruktorparameter und Methoden (z. B. setCorePoolSize()).
In einer weiteren Variante unseres Produzent-Lager-Konsument - Beispiels wird ein Threadpool
verwendet, um eine Anzahl von Konsumenten zu bedienen:
import java.util.concurrent.*;

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

class KonThread extends Thread {


private Lager lager;
private int custID;
final static private int MANZ = 2;
private int nr;

KonThread(Lager lager, int custID) {


super ("Kunde Nr " + custID);
this.custID = custID;
this.lager = lager;
}

@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)

Nr. 1: Produzent ergänzt 593 um 17:03:11 Uhr. Stand: 1593


Nr. 2: pool-1-thread-1 (Kunde 1) entnimmt 14 um 17:03:11 Uhr. Stand: 1579
Nr. 3: pool-1-thread-1 (Kunde 1) entnimmt 86 um 17:03:13 Uhr. Stand: 1493
Nr. 4: Produzent ergänzt 526 um 17:03:13 Uhr. Stand: 2019
Nr. 5: pool-1-thread-2 (Kunde 2) entnimmt 49 um 17:03:14 Uhr. Stand: 1970
Nr. 6: Produzent ergänzt 590 um 17:03:15 Uhr. Stand: 2560

Der Kunde 1 hat keine Wünsche mehr.

Nr. 7: pool-1-thread-2 (Kunde 2) entnimmt 79 um 17:03:17 Uhr. Stand: 2481


Nr. 8: pool-1-thread-1 (Kunde 3) entnimmt 32 um 17:03:17 Uhr. Stand: 2449

Der Kunde 2 hat keine Wünsche mehr.

Nr. 9: Produzent ergänzt 528 um 17:03:18 Uhr. Stand: 2977


Nr. 10: pool-1-thread-1 (Kunde 3) entnimmt 30 um 17:03:19 Uhr. Stand: 2947
Nr. 11: pool-1-thread-2 (Kunde 4) entnimmt 57 um 17:03:20 Uhr. Stand: 2890
Nr. 12: Produzent ergänzt 593 um 17:03:20 Uhr. Stand: 3483

Der Kunde 3 hat keine Wünsche mehr.

Nr. 13: Produzent ergänzt 562 um 17:03:23 Uhr. Stand: 4045


Nr. 14: pool-1-thread-1 (Kunde 5) entnimmt 33 um 17:03:23 Uhr. Stand: 4012
Nr. 15: pool-1-thread-2 (Kunde 4) entnimmt 55 um 17:03:24 Uhr. Stand: 3957
Nr. 16: pool-1-thread-1 (Kunde 5) entnimmt 27 um 17:03:25 Uhr. Stand: 3930
Nr. 17: Produzent ergänzt 555 um 17:03:26 Uhr. Stand: 4485

Der Kunde 4 hat keine Wünsche mehr.

Der Kunde 5 hat keine Wünsche mehr.

Nr. 18: Produzent ergänzt 515 um 17:03:30 Uhr. Stand: 5000


Nr. 19: Produzent ergänzt 597 um 17:03:32 Uhr. Stand: 5597
Nr. 20: Produzent ergänzt 585 um 17:03:36 Uhr. Stand: 6182

Der Produzent macht Feierabend.


810 Kapitel 15 Multithreading

Das IntelliJ-Projekt zum Beispiel befindet sich im Ordner


...\BspUeb\Multithreading\Threadpool\ExecutorService

15.4.2 Die Schnittstellen Callable<T> und Future<T>


Um die Kommunikation zwischen Threads bzw. Aufgaben zu verbessern wurde in Java 1.5 (bzw.
5.0) das generische Interface Callable<T> eingeführt, das ausschließlich die Methode call() vor-
schreibt:
public interface Callable<T> {
T call() throws Exception;
}
Implementiert eine Klasse dieses Interface, dann kann die call() - Methode eines Objekts in einem
eigenen Thread ausgeführt werden. Das gelingt zwar auch mit der run() - Methode einer Klasse, die
das Interface Runnable implementiert (vgl. Abschnitt 15.1.2), doch bietet das Interface Calla-
ble<T> einige Vorteile, die anschließend erläutert werden.
Im Unterschied zur Runnable-Methode run(), liefert die Callable-Methode call() einen Rückga-
bewert. Natürlich lässt sich eine Ergebnisübergabe auch per Runnable-Klasse realisieren, wobei
aber ein höherer Aufwand erforderlich ist, z. B.:
• Ergebnis in einer Instanzvariablen speichern
• Abfragemethode anbieten
Außerdem darf call() eine vorbereitungspflichtige Ausnahme (vgl. Abschnitt 11.5.2) werfen, was
der Methode run() aufgrund der Runnable-Definition verboten ist. Allerdings ist die Methode
call() nicht dazu verpflichtet, eine Ausnahme anzukündigen und zu werfen.
Es ist nicht möglich, ein Callable-Objekt als Argument an einen Thread-Konstruktor zu überge-
ben. Stattdessen lässt man das Callable-Objekt als Aufgabe durch einen Pool-Thread ausführen und
verwendet dazu ein Objekt aus einer Klasse, die das Interface ExecutorService implementiert und
daher u. a. die Methode submit() beherrscht:
public interface ExecutorService extends Executor {
<T> Future<T> submit(Callable<T> task);
. . .
}
Die generische Methode submit() erwartet einen Parameter vom Typ Callable<T> und liefert eine
Rückgabe vom Typ Future<T>. Durch die generische Schnittstelle Future<T> wird das zu erwar-
tende Ergebnis einer asynchron (in einem anderen Thread) ausgeführten Aufgabe repräsentiert. Es
sind die folgenden Methoden vorgeschrieben:
• public boolean isDone()
Der Status der Aufgabenausführung wird abgefragt.
• public T get()
Das Ergebnis wird mit einer unbegrenzten Wartezeit angefordert.
• public T get(long timeout, Timeout unit)
Das Ergebnis wird mit einer begrenzten Wartezeit angefordert.
• public boolean cancel(boolean myInterruptIfRunning)
Es wird versucht, die Ausführung der Aufgabe abzubrechen.
• public boolean isCanceled()
Es wird ermittelt, ob die Aufgabe vor der Fertigstellung abgebrochen wurde.
Ein ExecutorService-Objekt verschafft man sich in der Regel mit einer statischen Methode der
Klasse Executors, die wir im Abschnitt 15.4.1 kennengelernt haben, z. B.:
Abschnitt 15.4 Aufgaben per Threadpool erledigen 811

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 RandomNumberCruncher implements Callable<Double> {


public Double call() throws TimeoutException {
final int mxNum = 200_000_000;
final int mxTime = 5_000;
double d = 0.0;
long start = System.currentTimeMillis();
for (int i = 0; i < mxNum; i++) {
d += Math.random();
if (System.currentTimeMillis() - start > mxTime)
throw new TimeoutException();
}
return d/mxNum;
}
}
Objekte dieser Klasse berechnen in ihrer call() - Methode das arithmetische Mittel von reichlich
vielen double-Zufallszahlen aus dem Intervall [0, 1), realisieren also eine Zufallsgröße mit dem
Erwartungswert 0,5.
Um eine Berechnung zu starten und an das Ergebnis heran zu kommen, wird die ExecutorService-
Methode submit() mit einem RandomNumberCruncher-Objekt als Aktualparameter aufgerufen:
import java.text.DateFormat;
import java.util.Date;
import java.util.concurrent.*;

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

Warten auf call() - Ende (06.03.2022 02:25:42)


Warten auf call() - Ende (06.03.2022 02:25:43)
Warten auf call() - Ende (06.03.2022 02:25:44)
Warten auf call() - Ende (06.03.2022 02:25:45)
Warten auf call() - Ende (06.03.2022 02:25:46)

Mittelwert der Zufallszahlen: 0.5000068165995807

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)

Exception beim get() - Aufruf:


java.util.concurrent.ExecutionException: java.util.concurrent.TimeoutException

Das IntelliJ-Projekt mit dem Beispielprogramm finden Sie im Ordner


...\BspUeb\Multithreading\Threadpool\Callable

15.4.3 Threadpools mit Timer-Funktionalität


Mit der im Manuskript nicht behandelten Klasse Timer (im Paket java.util) ist es möglich, Aufga-
ben einmalig oder regelmäßig zu einer vorbestimmten Zeit in einem Hintergrund-Thread ausführen
zu lassen. Bei einer periodisch auszuführenden Aufgabe werden zeitliche Überschneidungen von
aufeinanderfolgenden Episoden verhindert. Bevor die nächste Wiederholung gestartet wird, muss
also die vorherige beendet sein. Als Einschränkungen der Klasse Timer sind zu nennen:
• Es wird nur ein Thread verwendet, der alle anstehenden Aufgaben nacheinander ausführt.
• Als Aufgaben sind nur Objekte aus einer von TimerTask abstammenden Klasse zugelassen.
Die Klasse ScheduledThreadPoolExecutor im Paket java.util.concurrent bietet mehr Flexibilität,
denn:
• Es wird ein Threadpool mit festem Umfang verwendet.
Die Pool-Threads können flexibel für unterschiedliche Aufgaben eingesetzt und damit effi-
zient genutzt werden.
• Als Aufgaben sind Objekte aus einer das Interface Runnable implementierenden Klasse zu-
gelassen.
Im folgenden Beispiel kommt ein ScheduledThreadPoolExecutor-Objekt zum Einsatz, das maxi-
mal zwei Threads einsetzen darf:
Abschnitt 15.4 Aufgaben per Threadpool erledigen 813

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();
}
}

Dem ScheduledThreadPoolExecutor werden über seine Methode scheduleAtFixedRate() drei


Aufträge erteilt, wobei jeweils mit individuellem Zeitplan (Initialverzögerung und Startabstand) die
run() - Methode eines Objekts der folgenden Klasse auszuführen ist:
import java.util.concurrent.ScheduledThreadPoolExecutor;
class ScheduledRunner implements Runnable {
private int nr;
private ScheduledThreadPoolExecutor stpe;
public ScheduledRunner(int nr, ScheduledThreadPoolExecutor stpe) {
this.nr = nr;
this.stpe = stpe;
}
private String formZeit() {
return java.text.DateFormat.getTimeInstance().format(new java.util.Date());
}
public void run() {
System.out.println("ScheduledRunner " + nr + ", Zeit: " + formZeit() +
", Aktive Pool-Threads: " + stpe.getActiveCount());
try {Thread.sleep(500*nr);
} catch (InterruptedException ignored) {Thread.currentThread().interrupt();}
}
}
Wie das folgende Ablaufprotokoll zeigt, variiert die Anzahl der aktiven Threads, wobei das Maxi-
mum 2 nicht überschritten wird:
ScheduledRunner 1, Zeit: 21:12:15, Aktive Pool-Threads: 1
ScheduledRunner 1, Zeit: 21:12:16, Aktive Pool-Threads: 1
ScheduledRunner 1, Zeit: 21:12:17, Aktive Pool-Threads: 1
ScheduledRunner 2, Zeit: 21:12:17, Aktive Pool-Threads: 2
ScheduledRunner 1, Zeit: 21:12:18, Aktive Pool-Threads: 2
ScheduledRunner 3, Zeit: 21:12:18, Aktive Pool-Threads: 2
ScheduledRunner 1, Zeit: 21:12:19, Aktive Pool-Threads: 2
ScheduledRunner 2, Zeit: 21:12:20, Aktive Pool-Threads: 2
ScheduledRunner 1, Zeit: 21:12:20, Aktive Pool-Threads: 2

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

15.5 Datenparallelität mit Hilfe des Fork-Join - Frameworks


Moderne Multithreading-Frameworks verfolgen das Ziel, die Programmierer von den Details und
Risiken der Multithreading-Programmierung zu entlasten, um die Nutzung der parallelen Program-
mierung zu fördern. Beim Fork-Join - Framework geht es nicht um die quasi-gleichzeitige Ausfüh-
rung mehrerer Aufgaben (Aufgabenparallelität), sondern um die beschleunigte Erledigung einer
Aufgabe durch die Zerlegung in simultan ausführbare Teilaufgaben (Datenparallelität). Wir werden
das Framework im Abschnitt 15.5.1 direkt verwenden. Im Abschnitt 15.5.2 über die parallelen Ag-
gregatoperationen mit Strömen ist das Framework als Basistechnik ebenfalls involviert.

15.5.1 Direkte Verwendung des Fork-Join - Frameworks


Das mit Java 7 eingeführte Fork-Join - Framework unterstützt die Aufteilung einer Aufgabe zur
parallelen Bearbeitung durch mehrere CPU-Kerne. Durch Verzweigung (forking) entstehen separate
„Produktionsstraßen“, die später wieder zusammengeführt werden (joining). Voraussetzung ist eine
Gesamtaufgabe, die sich in unabhängig ausführbare Teilaufgaben zerlegen lässt, sodass mehrere
Threads ohne nennenswerten Koordinierungsbedarf jeweils eine Teilaufgabe erledigen können.
Trotz der speziellen Anforderungen finden sich zahlreiche Beispiele für eine erfolgreiche Anwen-
dung des Fork-Join - Frameworks:
• In der Statistik ist zum Schätzen von Mittelwert bzw. Varianz für einen Array mit Zahlen
die Summe der einfachen bzw. quadrierten Werte zu bestimmen. Man kann segmentweise
Zwischensummen berechnen, die später zusammengeführt werden.
• Ein weiteres Beispiel aus dem Bereich der Statistik sind Bootstrap-Schätzmethoden, wobei
aus einer Primärstichprobe zahlreiche (z. B. 10.000) Sekundärstichproben gezogen werden,
um daraus jeweils dieselben Parameterschätzungen zu berechnen.
• Bei der Virenanalyse einer kompletten Festplatte können sich mehrere Threads jeweils um
einzelne Ordner kümmern.
• Beim Filtern einer Bitmap-Grafik lässt sich die Gesamtaufgabe oft in einzelne Kacheln zer-
legen.
Zur Lösung einer Aufgabe verwendet man im Fork-Join - Framework ein rekursives Verfahren, das
folgendermaßen beschrieben werden kann:

Liegt der Umfang der Aufgabe unterhalb


einer Schwelle?
Ja Nein

Erledige die Aufgabe (z.B. mit Zerlege die Aufgabe in zwei


einer sequentiellen Technik). Teilaufgaben.
Lasse die Teilaufgaben vom
Framework in eigenen Threads
erledigen.
Warte auf die Fertigstellung der
Teilaufgaben.
Führe die Ergebnisse zusammen.

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

• Wie viele Threads sollen beteiligt sein?


Als in der Regel geeignete Voreinstellung verwendet das Framework die Anzahl der verfüg-
baren CPU-Kerne.
• Wie viele Teilaufgaben sollen gebildet werden?
Über die Anzahl der Teilaufgaben entscheidet man durch die Wahl des Aufteilungskriteri-
ums. In der Regel wählt man die Zahl der Teilaufgaben höher als die Zahl der verfügbaren
CPU-Kerne, um dem Framework Flexibilität bei der Auftragsplanung zu lassen. Diese
kommt z. B. dann zum Tragen, wenn die Teilaufgaben unterschiedlich lange Bearbeitungs-
zeiten haben (Grossmann 2012). In der Praxis zeigt sich, dass die Anzahl der Teilaufgaben
abgesehen von extremen Fällen (überhaupt keine Aufteilung, maximale Zersplitterung) we-
nig Einfluss auf die gesamte Bearbeitungsdauer hat.
Für eine einfache Anwendung des Fork-Join - Frameworks werden die folgenden Klassen (aus dem
Paket java.util.concurrent.forkjoin) benötigt:
• Zur Modellierung einer Teilaufgabe verwendet man eine Ableitung der generischen Klas-
se ForkJoinTask<T> als Basisklasse für eine eigene, aufgabenspezifische
Klassendefinition. Müssen die Teilaufgaben kein Ergebnis melden (z. B. beim Zerlegen ei-
ner Bitmap-Filterung auf einzelne Kacheln), dann verwendet man die von ForkJoin<Void>
abgeleitete Klasse RecursiveAction als Basisklasse, anderenfalls kommt die von
ForkJoin<Void> abgeleitete Klasse RecursiveTask<T> zum Einsatz. In der eigenen Auf-
gabenklasse sind folgende Kompetenzen zu implementieren:
o Direktlösung einer hinreichend kleinen Aufgabe
o Rekursives Abspalten von „Aufgabehälften“
• Für die Verwaltung der Teilaufgaben und der Pool-Threads verwendet man ein Objekt
der Klasse ForkJoinPool, die das Interface ExecutorService implementiert. Jeder Pool-
Thread besitzt eine Warteschlange von Teilaufgaben, die er zu erledigen hat. Wenn ein
Thread die eigene Aufgabenwarteschlange abgearbeitet hat und warten muss, dann über-
nimmt er Teilaufgaben aus den Warteschlangen anderer Pool-Threads (work stealing). Zum
Starten der Auftragsabwicklung erteilt man der ForkJoinPool-Instanz den Auftrag invoke()
und übergibt als Parameter ein Objekt der aufgabenspezifischen eigenen Klasse, das den
kompletten Arbeitsumfang repräsentiert.
Neben dem work stealing besteht eine weitere Besonderheit eines ForkJoinPool - Threadpools im
Vergleich einem anderen ExecutorServic - Threadpool darin, dass sogenannte Daemon-Threads
zum Einsatz kommen. Von den sogenannten Benutzer-Threads, die uns bisher ausschließlich be-
gegnet sind, unterscheiden sich die Daemon-Threads durch ...
• eine niedrigere Priorität,
• die Unfähigkeit, ein Programm am Leben zu erhalten.
Ein Java Programm endet, wenn alle Benutzer-Threads ihre Tätigkeit eingestellt haben, und nur
noch Daemon-Threads vorhanden sind. Weil die Daemon-Threads in dieser Lage abgebrochen ist,
muss eventuell ein Benutzer-Thread für eine geordnete Terminierung der Daemon-Threads sorgen.
Weitere Details zu den Daemon-Threads folgen im Abschnitt 15.10.1.
Wenn man keinen ForkJoinPool - Threadpool explizit (per Konstruktor) erstellt, dann kommt im
Fork-Join - Framework der sogenannte Common Pool zum Einsatz, was zugunsten einer sparsamen
Ressourcen-Verwendung meist sinnvoll ist. Eine Referenz zum statisch erstellten Common Pool
kann über die ForkJoinPool-Methode commonPool() abgerufen werden:
public static ForkJoinPool commonPool()
Man kann und muss diesen Threadpool nicht durch einen Aufruf der Methode shutdown() termi-
nieren. Am Programmende werden der Common Pool und die noch ausgeführten Aufgaben auto-
matisch beendet. Es kann aber erforderlich sein, vor dem Programmende den im Common Pool
816 Kapitel 15 Multithreading

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;

public FacTask(int start, int ende, int schwelle) {


this.start = start;
this.ende = ende;
this.schwelle = schwelle;
}

private BigDecimal product(int start, int ende) {


BigDecimal fac = new BigDecimal(start);
for (int i = start+1; i <= ende; i++)
fac = fac.multiply(new BigDecimal(i));
return fac;
}

@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();
}

static public BigDecimal factorial(int argument) {


return factorial(argument, Runtime.getRuntime().availableProcessors()*10);
}

private static class FacTask extends RecursiveTask<BigDecimal> {


. . .
}
}
In der statischen FacForkJoin-Methode factorial() wird ...
• ein Objekt der Aufgabenklasse FacTask mit der kompletten Aufgabe erstellt,
• ein Objekt der Aufgabenverwaltungsklasse ForkJoinPool erstellt,
• die Bearbeitung durch einen Aufruf der ForkJoinPool-Methode invoke() gestartet, die als
Parameter das FacTask-Objekt erhält,

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)

Laufzeit mit Fork-Join in Millisekunden: 2019 (Ergebnis: 3,347321e+213236)


Laufzeit mit Fork-Join in Millisekunden: 1298 (Ergebnis: 3,347321e+213236)
Laufzeit mit Fork-Join in Millisekunden: 1427 (Ergebnis: 3,347321e+213236)
Laufzeit mit Fork-Join in Millisekunden: 1308 (Ergebnis: 3,347321e+213236)
Laufzeit mit Fork-Join in Millisekunden: 1273 (Ergebnis: 3,347321e+213236)
Die Überlegenheit der Multithreading-Lösung fällt deutlicher aus, als es bei 2 realen CPU-Kernen
plus Hyperthreading zu erwarten war. Das liegt eventuell zum Teil daran, dass die vier Kerne, von
denen bei der Single-Thread - Lösung nur einer zum Einsatz kommt, unterschiedlich leistungsfähig
sind.
Ein IntelliJ-Projekt mit dem Beispielprogramm befindet sich im Ordner
...\BspUeb\Multithreading\Fork-Join\Direkte Verwendung

15.5.2 Parallele Aggregatoperationen mit Strömen


Im Abschnitt 12.2.5.4.2 wurde bereits ein Verfahren zur Fakultätsberechnung unter Verwendung
der in Java 8 eingeführten Ströme mit seriellen bzw. parallelen Aggregatoperationen vorgestellt.
Wir verwenden das Beispiel erneut und beschränken uns auf das automatisierte Multithreading bei
parallelen Strömen, das auf der Fork-Join - Technik basiert. Das folgende Programm
Abschnitt 15.6 Aufgabenparallelität mit Hilfe der Klasse CompletableFuture<T> 819

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.

15.6 Aufgabenparallelität mit Hilfe der Klasse CompletableFuture<T>


Bei dem im Abschnitt 15.5 behandelten Fork-Join - Framework geht es um die beschleunigte Erle-
digung einer Aufgabe durch die Zerlegung in simultan ausführbare Teilaufgaben (Datenparalleli-
tät). Mit Hilfe der in Java 8 eingeführten generische Klasse CompletableFuture<T> wird hingegen
die quasi-gleichzeitige Ausführung mehrerer Aufgaben (Aufgabenparallelität) unter Beachtung von
Abhängigkeitsbeziehungen zwischen den Aufgaben unterstützt.
Die Klasse CompletableFuture<T> implementiert das im Abschnitt 15.4.2 beschriebene Interface
Future<T> und beherrscht außerdem u. a. die folgenden, offenbar für die Benennung verantwortli-
chen Methoden:
• public boolean complete(T value)
Wenn die Aufgabe noch nicht abgeschlossen ist, wird sie in diesen Zustand versetzt, und es
wird ein Wert für die get() - Rückgabe festgelegt.
• public boolean completeExceptionally(Throwable ex)
Wenn die Aufgabe noch nicht abgeschlossen ist, dann wird sie als gescheitert deklariert, und
es wird ein von get() zu werfendes Ausnahmeobjekt festgelegt.
Der wesentliche, durch zahlreiche weitere Methoden unterstützte Zweck der Klasse
CompletableFuture<T> besteht darin, die parallele Bearbeitung von mehreren Aufgaben (alias:
Verarbeitungsschritten, Verarbeitungsstationen, engl.: stages) mit flexibel definierbaren Abhängig-
820 Kapitel 15 Multithreading

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):

CF21 CF31 CF41

CF51

CF1 CF32 CF42

CF22 CF43 CF52

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.

15.6.1 Asynchrone Verarbeitung einer einzelnen Aufgabe


Die im aktuellen Abschnitt behandelten statischen Methoden der Klasse CompletableFuture<T>
starten eine asynchron per Pool-Thread zu verarbeitende Aufgabe ohne bzw. mit Rückgabewert.

15.6.1.1 Aufgaben ohne Rückgabewert


An die statische CompletableFuture<T> - Methode runAsync() wird ein asynchron auszuführen-
des Runnable-Objekt übergeben:
public static CompletableFuture<Void> runAsync(Runnable runnable)
Die run() - Methode des Parameterobjekts vom Typ Runnable
public void run()
wird asynchron von einem Thread aus dem sogenannten Common Pool ausgeführt (siehe Abschnitt
15.5.1). Weil die run() - Methode den Rückgabetyp void besitzt, liefert runAsync() ein Objekt von
der parametrisierten Klasse CompletableFuture<Void> zurück. Im folgenden Programm wird eine
Kosolenausgabe asynchron durch einen Thread aus dem Common Pool ausgeführt:
Quellcode Ausgabe
import java.util.concurrent.CompletableFuture; Waiting
Done
class RunAsync {
public static void main(String[] args) throws InterruptedException {
CompletableFuture<Void> cfv = CompletableFuture.runAsync(
() -> System.out.println("Done"));
while (!cfv.isDone()) {
System.out.println("Waiting");
Thread.sleep(100);
}
}
}
Abschnitt 15.6 Aufgabenparallelität mit Hilfe der Klasse CompletableFuture<T> 821

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();

In dem von newCachedThreadPool() erstellten Pool sind Benutzer-Threads tätig, sodass z. B.


nach dem Ende der main() - Methode und damit nach dem Ende des main-Threads die Aufgaben-
bearbeitung fortgesetzt wird. Weil der Pool seine Threads bei Beschäftigungsmangel nicht sofort
terminiert, muss er oft explizit per shutdown() terminiert werden, damit ein erwartetes bzw. ange-
fordertes Programmende sofort eintritt.
Ein ForkJoinPool muss hingegen beim Programmende nicht explizit terminiert werden, weil die
hier tätigen Daemon-Threads automatisch enden (siehe Abschnitt 15.5.1). Wir kennen dieses Ver-
halten vom Common Pool, der eine ForkJoinPool-Instanz ist. Allerdings muss man eventuell dafür
sorgen, dass geplante bzw. schon gestartete Aufgaben noch ausgeführt werden, z. B.
ForkJoinPool fjp = new ForkJoinPool();
CompletableFuture<Void> cfv = CompletableFuture.runAsync(
() -> System.out.println("Done"), fjp);
cfv.join();

15.6.1.2 Aufgaben mit Rückgabewert


An die statische CompletableFuture<T> - Methode supplyAsync() wird ein asynchron auszufüh-
rendes Supplier<U> - Objekt übergeben:1
public static <U> CompletableFuture<U> supplyAsync(Supplier<U> supplier)
Die funktionale Schnittstelle Supplier<U> (im Paket java.util.function) verlangt eine Methode
namens get(), die das Ergebnis liefert:
public U get()
Im folgenden Programm wird das Supplier<U> - Objekt per Lambda-Ausdruck realisiert:

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.

15.6.2.1 Unproduktiver Nachfolger zu einer Aufgabe ohne Rückgabewert


In diesem Abschnitt mit einem nicht ganz ernst gemeinten Titel geht es um Folgeaufgaben, die auf
einem Runnable-Objekt basieren, also eine Methode ohne Parameter und ohne Rückgabe ausfüh-
ren:
Abschnitt 15.6 Aufgabenparallelität mit Hilfe der Klasse CompletableFuture<T> 823

public void run()


Ein thenRun() - Aufruf an ein CompletableFuture<T> - Objekt startet eine solche Folgeaufgabe
und liefert ein CompletableFuture<Void> - Objekt zurück:
public CompletableFuture<Void> thenRun(Runnable action)
Die Folgeaufgabe wird ...
• im selben Thread ausgeführt wie die Vorgängeraufgabe, wenn diese beim thenRun() -
Aufruf noch läuft,
• anderenfalls im aktuellen Thread.
Im folgenden Programm startet der main-Thread per runAsync() eine Aufgabe ohne Rückgabe, die
vom Common Pool ausgeführt wird. Weil sich diese Aufgabe eine Ruhepause gönnt, ist sie noch
aktiv, wenn der main-Thread per runApply() eine Folgeaufgabe startet:
import java.util.concurrent.CompletableFuture;

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());
});

CompletableFuture<Void> cf2 = cf1.thenRun(() -> {


System.out.println("Runner: " + 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

public CompletableFuture<Void> thenRunAsync(Runnable action)


Die Folgeaufgabe wird ...
• vom selben Thread ausgeführt wie die Vorgängeraufgabe, wenn die Vorgängeraufgabe beim
thenRunAsync() - Aufruf noch läuft,
• anderenfalls von irgendeinem Thread aus dem Common Pool.
Im folgenden Programm erhält der Common Pool nach der Startaufgabe per runAsync() noch eine
Zusatzaufgabe, sodass der Common Pool die Personalstärke 2 erreicht. Während der main-Thread 3
Sekunden schläft, sind die Start und die Zusatzaufgabe abgeschlossen. Dann wird per
thenRunAsync() die Folgeaufgabe gestartet:
import java.util.concurrent.CompletableFuture;

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());
});

CompletableFuture<Void> cf2 = CompletableFuture.runAsync(() -> {


try {Thread.sleep(1000);}
catch (InterruptedException ix) {Thread.currentThread().interrupt();}
System.out.println("Additional: " + Thread.currentThread().getName());
});

Thread.sleep(3000);

CompletableFuture<Void> cf3 = cf1.thenRunAsync(() -> {


System.out.println("Runner: " + Thread.currentThread().getName());
});

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

An die folgende thenRunAsync() - Überladung


public CompletionStage<Void> thenRunAsync(Runnable action, 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> cf3 = cf1.thenRunAsync(() -> {
System.out.println("Runner: " + Thread.currentThread().getName());
}, es);
. . .
es.shutdown();
Abschnitt 15.6 Aufgabenparallelität mit Hilfe der Klasse CompletableFuture<T> 825

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

15.6.2.2 Produktiver Nachfolger zu einer Aufgabe mit Rückgabewert


Ein thenApply() - Aufruf an ein CompletableFuture<T> - Objekt vereinbart eine Fortsetzungs-
aufgabe, in der die apply() - Methode eines Function<T,U> - Objekts ausgeführt wird:
public <U> CompletableFuture<U> thenApply(Function<? super T,? extends U> function)
Die apply() - Methode erhält das Resultat im angesprochenen CompletitonStage<T> - Objekt per
Parameter und liefert ein Ergebnis vom Typ U. Dementsprechend erhält man von thenAppy() eine
Rückgabe vom Typ CompletableFuture<U>.
Die Fortsetzungsaufgabe wird ...
• im selben Thread ausgeführt wie die Vorgängeraufgabe, wenn diese beim thenApply() -
Aufruf noch läuft,
• anderenfalls im aktuellen Thread.
Im folgenden Programm startet der main-Thread per supplyAsync() eine Aufgabe mit dem Wert 1
als Ergebnis, bevor er sich drei Sekunden zur Ruhe begibt. Anschließend startet er per thenApply()
eine Fortsetzungsaufgabe, die den Ausgangswert inkrementiert:
import java.util.concurrent.CompletableFuture;

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);

CompletableFuture<Integer> cf2 = cf1.thenApply(i -> {


System.out.println("Incrementer: " + Thread.currentThread().getName());
return ++i;
});

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);

CompletableFuture<Void> cfv = CompletableFuture.runAsync(() -> {


try {Thread.sleep(3000);}
catch (InterruptedException ix) {Thread.currentThread().interrupt();}
System.out.println("Sleeper: " + Thread.currentThread().getName());
});

CompletableFuture<Integer> cf2 = cf1.thenApplyAsync(i -> {


System.out.println("Incrementer: " + Thread.currentThread().getName());
try {Thread.sleep(1000);}
catch (InterruptedException ix) {Thread.currentThread().interrupt();}
return ++i;
});

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

Dann liefert das aktuelle Beispielprogramm die folgende Ausgabe:


First: ForkJoinPool.commonPool-worker-1
Incrementer: ForkJoinPool.commonPool-worker-2
2
Sleeper: ForkJoinPool.commonPool-worker-1

An die folgende thenApplyAsync() - Überladung


public <U> CompletableFuture<U> thenApplyAsync(
Function<? super T,? extends 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<Integer> cf2 = cf1.thenApplyAsync(i -> {
System.out.println("Incrementer: " + Thread.currentThread().getName());
try {Thread.sleep(1000);}
catch (InterruptedException ix) {Thread.currentThread().interrupt();}
return ++i;
}, es);
. . .
es.shutdown();
Wie die Ausgabe des modifizierten Beispielprogramms zeigt, wird die Fortsetzungsaufgabe jetzt
von einem speziellen Threadpool (namens pool-1) ausgeführt:
First: ForkJoinPool.commonPool-worker-1
Incrementer: pool-1-thread-1
2
Sleeper: ForkJoinPool.commonPool-worker-1

15.6.2.3 Konsumierender Nachfolger zu einer Aufgabe mit Rückgabewert


Um eine Fortsetzungsaufgabe zu erstellen, die das Ergebnis des Vorgängers konsumiert, aber selbst
kein Ergebnis produziert, verwendet man die CompletableFuture<T> - Methoden thenAccept()
oder thenAcceptAsync(). Für die Methode thenAccept() besteht wie bei der im Abschnitt 15.6.2.2
beschriebenen Methode thenApply() eine potentiell unerwünschte Unsicherheit hinsichtlich der
synchronen oder asynchronen Ausführung. Daher beschränken wir uns im aktuellen Abschnitt auf
die Methode thenAcceptAsync():
public CompletableFuture<Void> thenAcceptAsync(Consumer<? super T> action)
Als Parameter wird ein Objekt aus einer Klasse erwartet, die das funktionale Interface
Consumer<? super T> implementiert. Die dabei verlangte Methode
public void accept(? super T t)
muss ein Argument aus der Klasse T oder aus einer Basisklasse von T verarbeiten und den Rückga-
betyp void besitzen. Dementsprechend liefert thenAcceptAsync ein CompletionStage<Void> -
Objekt.
In der aktuell beschriebenen thenAcceptAsync() - Überladung wird kein ausführender Threadpool
benannt, sodass der Common Pool zum Einsatz kommt. Dieser Threadpool wird am Ende des Pro-
gramms automatisch terminiert, was für dort laufende Aufgaben ein abruptes Ende zur Folge hat.
Durch einen Aufruf der ForkJoinPool-Methode awaitQuiescence() kann man dafür sorgen, dass
vor dem Terminieren des Pools noch laufende bzw. anstehende Aufgaben geschlossen werden, so-
fern die festgelegte maximale Wartezeit genügt:
828 Kapitel 15 Multithreading

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;
});

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();}
});

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.

15.6.3 Folgeaufgaben mit zwei Vorgängern


Bisher haben wir CompletableFuture<T> - Methoden zur Erstellung von linearen Verarbeitungs-
sequenzen kennengelernt. Nun werden Methoden vorgestellt, die zwei Vorgängeraufgaben zusam-
menführen, sodass komplexere Prozesse möglich werden.

15.6.3.1 AND-Zusammenführung von zwei Aufgaben mit Rückgabe


Um eine Fortsetzungsaufgabe zu starten, die das erfolgreiche Ende von zwei Vorgängeraufgaben
voraussetzt, verwendet man die CompletableFuture<T> - Methoden thenCombine() oder
thenCombineAsync(). Für die Methode thenCombine() besteht wie bei der im Abschnitt 15.6.2.2
beschriebenen Methode thenApply() eine potentiell unerwünschte Unsicherheit hinsichtlich der
synchronen oder asynchronen Ausführung. Daher beschränken wir uns im aktuellen Abschnitt auf
die Methode thenCombineAsync():
public <U,V> CompletableFuture<V> thenCombineAsync(
CompletionStage<? extends U> other,
BiFunction<? super T,? super U,? extends V> function)
Dem angesprochenen CompletableFuture<T> - Objekt werden zwei Parameter übergeben:
• ein CompletionStage<? extends U> - Objekt
Die zu diesem Objekt gehörende Aufgabe muss ebenso erfolgreich ausgeführt worden sein
wie die zum angesprochenen CompletableFuture<T> - Objekt gehörende Aufgabe.
• BiFunction<? super T,? super U,? extends V>
Die Klasse des zweiten Parameterobjekts muss eine Methode mit den folgenden Eigenschaf-
ten besitzen:
o Aufgrund der beiden Parameter mit den Typen ? super T bzw. ? super U können
die Ergebnisse der beiden Vorgängeraufgaben verarbeitet werden.
o Die Rückgabe erfüllt den Typ V.
Als Rückgabe erhält man von der Methode thenCombineAsync() ein Objekt vom Typ
CompletableFuture<V>, dessen Aufgabe mit dem Ergebnistyp V vom Common Pool ausgeführt
wird.
Im folgenden Programm liefern zwei per supplyAsync() gestartet Aufgaben jeweils ein Ergebnis
vom Typ Integer. Wir stellen uns vor, dass die Ergebnisse aus aufwändigen, simultan und asyn-
chron ausgeführten Aufgaben resultieren. Durch den die erste Aufgabe gerichteten
thenCombineAsync() - Aufruf wird eine neue Aufgabe gestartet, die aus den beiden Integer-
830 Kapitel 15 Multithreading

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;
});

CompletableFuture<Integer> cf2 = CompletableFuture.supplyAsync(() -> {


try {Thread.sleep(1000);}
catch (InterruptedException ix) {Thread.currentThread().interrupt();}
System.out.println("Second: " + Thread.currentThread().getName());
return 3;
});

CompletableFuture<String> cf3 = cf1.thenCombineAsync(cf2, (i, j) -> {


System.out.println("Combiner: " + Thread.currentThread().getName());
return i + " hoch " + j +" = " + Math.pow(i, j);
});

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

An die folgende thenCombineAsync() - Überladung


public <U,V> CompletableFuture<V> thenCombineAsync(
CompletionStage<? extends U> other,
BiFunction<? super T,? super U,? extends V> 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.thenCombineAsync(cf2, (i, j) -> {
try {Thread.sleep(1000);}
System.out.println("Combiner: " + Thread.currentThread().getName());
return i + " hoch " + j +" = " + Math.pow(i, j);
}, 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:
First: ForkJoinPool.commonPool-worker-1
Second: ForkJoinPool.commonPool-worker-2
Combiner: pool-1-thread-1
2 hoch 3 = 8.0
Abschnitt 15.6 Aufgabenparallelität mit Hilfe der Klasse CompletableFuture<T> 831

15.6.3.2 OR-Zusammenführung von zwei Aufgaben mit Rückgabe


Eine durch die Methoden applyToEither() oder applyToEitherAsync() der Klasse
CompletableFuture<T> gestartete Aufgabe wird ausgeführt, sobald eine von zwei Vorgängerauf-
gaben erfolgreich beendet worden ist. Für die Methode applyToEither() besteht wie bei der im
Abschnitt 15.6.2.2 beschriebenen Methode thenApply() eine potentiell unerwünschte Unsicherheit
hinsichtlich der synchronen oder asynchronen Ausführung. Daher beschränken wir uns im aktuellen
Abschnitt auf die Methode applyToEitherAsync():
public <U> CompletableFuture<U> applyToEitherAsync(
CompletionStage<? extends T> other,
Function<? super T,U> function)
Dem angesprochenen CompletableFuture<T> - Objekt werden zwei Parameter übergeben:
• ein CompletionStage<? extends T> - Objekt
Entweder muss die zu diesem Objekt gehörende Aufgabe erfolgreich ausgeführt worden
sein oder die zum angesprochenen CompletableFuture<T> - Objekt gehörende Aufgabe,
damit die Kombinationsaufgabe starten kann.
• Function<? super T, U>
Die Klasse des zweiten Parameterobjekts muss die folgende Methode beherrschen:
public U apply(? super T t)
Diese Methode kann das Ergebnis der abgeschlossenen Vorgängeraufgabe verarbeiten und
eine Rückgabe vom Typ U liefern.
Als Rückgabe erhält man von der Methode applyToEitherAsync() ein Objekt vom Typ
CompletableFuture<U>, dessen Aufgabe mit dem Ergebnistyp U vom Common Pool ausgeführt
wird.
Im folgenden Programm liefern zwei per supplyAsync() gestartet Aufgaben jeweils ein Ergebnis
vom Typ String. Wir stellen aus vor, dass die Ergebnisse aus aufwändigen, simultan und asynchron
ausgeführten Aufgaben resultieren, die z. B. jeweils einen Lieferanten einer holländischen Blumen-
börse repräsentieren. Durch den an das erste CompletableFuture<String> - Objekt gerichteten
applyToEitherAsync() - Aufruf wird eine neue Aufgabe gestartet, die aus den beiden String-
Zwischenergebnissen ein neues CompletableFuture<String> - Objekt erstellt, das als Ergebnis ein
neues String-Objekt liefert. Die Methode main() richtet an die Kombinationsaufgabe einen (blo-
ckierenden) join() - Aufruf, um das Ergebnis zu erhalten.
import java.util.concurrent.CompletableFuture;

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";
});

CompletableFuture<String> cf2 = CompletableFuture.supplyAsync(() -> {


try {Thread.sleep(1000);}
catch (InterruptedException ix) {Thread.currentThread().interrupt();}
System.out.println("Second: " + Thread.currentThread().getName());
return "Nelken";
});
832 Kapitel 15 Multithreading

CompletableFuture<String> cf3 = cf1.applyToEitherAsync(cf2, s -> {


System.out.println("Combiner: " + Thread.currentThread().getName());
return "Heute im Angebot: " + s;
});

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.3.3 Zusammenführung von zwei Aufgaben ohne Rückgabe


Auch zu zwei Aufgaben ohne Rückgabe lässt sich über CompletableFuture<T> - Methoden eine
Folgeaufgabe vereinbaren, die gestartet wird, ...
• nachdem beide Vorgängeraufgaben erfolgreich abschlossen wurde,
Dazu dienen die Methoden runAfterBoth() und runAfterBothAsync().
• nachdem eine Vorgängeraufgabe erfolgreich abgeschlossen wurde.
Dazu dienen die Methoden runAfterEither() und runAfterEitherAsync().
Die genannten Methoden liefern ein Objekt vom Typ CompletableFuture<Void>. Nähere Erläute-
rungen und Beispielprogramm sind wohl nicht erforderlich.
Abschnitt 15.6 Aufgabenparallelität mit Hilfe der Klasse CompletableFuture<T> 833

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.

15.6.4.1 Ausnahme diagnostizieren


Eine für das Abbrechen einer Verarbeitungskette verantwortliche unbehandelte Ausnahme wird
erneut geworfen, wenn man das Ergebnis der betroffenen Aufgabe oder das Ergebnis einer abhän-
gigen Aufgabe per get() oder join() abfragt.
Der Status einer bereits terminierten Aufgabe lässt sich über die CompletableFuture<T> - Metho-
de isCompletedExceptionally() feststellen, von der keine Ausnahme zu erwarten ist.
Wird keine von den genannten Methoden aufgerufen, dann bleibt das Scheitern einer Verarbei-
tungskette unbemerkt.
Im folgenden Programm
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ForkJoinPool;
import java.util.concurrent.TimeUnit;

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;
});

CompletableFuture<Integer> cf2 = cf1.thenApplyAsync(i -> {


System.out.println("Incrementer: " + Thread.currentThread().getName());
return ++i;
});

CompletableFuture<Void> result = cf2.thenAcceptAsync(i -> {


System.out.println("Accepter: " + Thread.currentThread().getName());
System.out.println("Ergebnis = " + i);
});

ForkJoinPool.commonPool().awaitQuiescence(3000, TimeUnit.MILLISECONDS);
}
}
834 Kapitel 15 Multithreading

mit der Aufgabensequenz


CompletableFuture<Integer>

CompletableFuture<Integer>

CompletableFuture<Void>
endet die erste Aufgabe mit einer Ausnahme vom Typ IllegalStateException. Infolgedessen wer-
den die Methoden thenApplyAsync() und thenAcceptAsync() nicht ausgeführt. Wie die Ausgabe
des Programms zeigt, bleibt die verursachende Ausnahme unbemerkt:
Starter: ForkJoinPool.commonPool-worker-1
Das Programm wartet am Ende, bis die vom Common Pool bereits begonnenen Aufgaben terminiert
sind. Folglich kann die erste Aufgabe ihre Tätigkeit protokollieren, bevor sie an einer Ausnahme
scheitert, sodass die beiden Folgeaufgaben nicht gestartet werden.
Wir ergänzen am Ende der main() - Methode einige Maßnahmen zur Diagnose einer Havarie,
Thread.sleep(1000);
System.out.println("\nVerarbeitung abgebrochen? " +
result.isCompletedExceptionally());

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

Verarbeitung abgebrochen? true

java.util.concurrent.ExecutionException: java.lang.IllegalStateException: Starter failed

Starter: java.util.concurrent.CompletableFuture@58372a00[Completed exceptionally]


Incrementer: java.util.concurrent.CompletableFuture@4dd8dc3[Completed exceptionally]
Result: java.util.concurrent.CompletableFuture@6d03e736[Completed exceptionally]
Es zeigt sich außerdem, dass alle drei Aufgaben den Status Completed exceptionally haben.
Bei einem Programmeinsatz ohne Ausnahme
if (false)
throw new IllegalStateException("Starter failed");
resultiert die Ausgabe:
Abschnitt 15.6 Aufgabenparallelität mit Hilfe der Klasse CompletableFuture<T> 835

Starter: ForkJoinPool.commonPool-worker-1
Incrementer: ForkJoinPool.commonPool-worker-1
Accepter: ForkJoinPool.commonPool-worker-1
Ergebnis = 2

Verarbeitung abgebrochen? false

result = null

Starter: java.util.concurrent.CompletableFuture@58372a00[Completed normally]


Incrementer: java.util.concurrent.CompletableFuture@4dd8dc3[Completed normally]
Result: java.util.concurrent.CompletableFuture@6d03e736[Completed normally]
Zum CompletableFuture<Void> - Objekt result liefert get() den Ergebniswert null, und alle
Aufgaben besitzen den Status Completed normally.
In den folgenden Abschnitten werden drei CompletableFuture<T> - Methoden vorgestellt, die
eine in den Verarbeitungsprozess integrierte Ausnahmebehandlung ohne try-catch - Anweisung
ermöglichen.

15.6.4.2 Ausnahme behandeln und Verarbeitungskette fortsetzen


Die im aktuellen Abschnitt vorgestellten CompletableFuture<T> - Methoden exceptionally() und
handleAsync() werden im Unterschied zu den Methoden thenRunAsync(), thenApplyAsync(),
thenAcceptAsync() etc. auch dann ausgeführt, wenn eine vorausgesetzte Aufgabe aufgrund eines
Ausnahmefehlers gescheitert ist. Die Methoden exceptionally() und handleAsync() liefern ein
neues Aufgabenobjekt und setzen die havarierte Verarbeitungskette wieder in Gang.

15.6.4.2.1 Fortsetzung mit einem neuem Wert vom Typ T


Erhält ein CompletableFuture<T> - Objekt einen Aufruf der Methode exceptionally(), dann resul-
tiert ein neues Objekt vom selben Typ:
public CompletableFuture<T> exceptionally(Function<Throwable,? extends T> function)
Im resultierenden Verarbeitungsschritt wird die apply() - Methode eines Function<Throwable,?
extends T> - Objekts ausgeführt. Diese Methode erhält per Parameter vom Typ Throwable die in
der angesprochenen Aufgabe geworfene Ausnahme, und ihr Rückgabetyp muss vom Ergebnistyp
der angesprochenen Aufgabe abstammen. Damit kann ein Ersatzwert für die ausgefallene Produkti-
on der gescheiterten Aufgabe festgelegt werden.
Wir dürfen uns vorstellen, dass die Methode exceptionally() nur dann aufgerufen wird, wenn in der
angesprochenen Aufgabe oder in einer vorausgesetzten eine Ausnahme aufgetreten ist. Dann wird
der Ausnahmefehler beseitigt und die Verarbeitung mit einem Ersatzwert fortgesetzt.
Im folgenden Programm protokolliert das Funktionsobjekt die Ausnahme und legt einen Ersatz für
den Wert fest, den die Vorgängeraufgabe aufgrund des Ausnahmefehlers nicht liefern konnte. Da-
mit ist die Ausnahme erledigt, und die Verarbeitungskette wird fortgesetzt:
import java.util.concurrent.CompletableFuture;

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

cf1 = cf1.exceptionally(ex -> {


System.out.println("\nAusnahme: " + ex);
System.out.println("Reset to 0: " +
Thread.currentThread().getName() + "\n");
return 0;
});

CompletableFuture<Integer> cf2 = cf1.thenApplyAsync(i -> {


System.out.println("Incrementer: " + Thread.currentThread().getName());
return ++i;
});

CompletableFuture<Void> result = cf2.thenAcceptAsync(i -> {


System.out.println("Accepter: " + Thread.currentThread().getName());
System.out.println("\nErgebnis = " + i);
});

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

15.6.4.2.2 Fortsetzung mit einem neuem Wert vom Typ U


Erhält ein CompletableFuture<T> - Objekt einen Aufruf der Methode handleAsync(), dann resul-
tiert ein neuer Verarbeitungsschritt vom Typ CompletableFuture<U>:
public <U> CompletableFuture<U> handleAsync(
BiFunction<? super T,Throwable,? extends U> function)
Im Unterschied zur Methode exceptionally() wird die Methode handleAsync() auf jeden Fall auf-
gerufen (also unabhängig vom Auftreten einer Ausnahme). Vom neuen Verarbeitungsschritt wird
die apply() - Methode eines BiFunction<? super T,Throwable,? extends U> - Objekts ausgeführt
wird. Diese Methode erhält zwei Parameterobjekte:
• das Ergebnis der angesprochenen Aufgabe, wenn diese fehlerfrei abgeschlossen wurde,
• die in der angesprochenen Aufgabe oder in einer vorausgesetzten Aufgabe geworfene Aus-
nahme.
Genau eines von den beiden Parameterobjekten ist von null verschieden, sodass die Methode ihr
Verhalten leicht anpassen kann. Für die apply() - Rückgabe ist ein beliebiger Referenztyp U zuge-
lassen. Bei handleAsync() kann also (wie bei thenApplyAsync()) eine beliebige Konkretisierung
der generischen Klasse CompletableFuture<U> als Typ für den neuen Verarbeitungsschritt ge-
wählt werden, wobei natürlich auch der Typ des angesprochenen CompletableFuture<T> - Ob-
jekts erlaubt ist.
Im folgenden Programm
import java.util.concurrent.CompletableFuture;

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;
});

CompletableFuture<Integer> cf2 = cf1.handleAsync((i, ex) -> {


if (ex != null) {
System.out.println("\nAusnahme: " + ex);
System.out.println("Reset to 0: " +
Thread.currentThread().getName() + "\n");
i = 0;
}
System.out.println("Incrementer: " + Thread.currentThread().getName());
return ++i;
});

CompletableFuture<Void> result = cf2.thenAcceptAsync(i -> {


System.out.println("Accepter: " + Thread.currentThread().getName());
System.out.println("\nResult = " + i);
});

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.

15.6.4.3 Ausnahme protokollieren


Wenn das Scheitern eines vorausgesetzten Verarbeitungsschritts protokolliert werden soll, ohne die
verantwortliche Ausnahme zu neutralisieren und die Verarbeitung fortzusetzen, dann ist die
CompletableFuture<T> - Methode whenCompleteAsync() das Mittel der Wahl:
CompletableFuture<T> whenCompleteAsync(
BiConsumer<? super T, ? super Throwable> action)
Der Methode ist ein Funktionsobjekt zu übergeben, das eine Methode mit dem Rückgabetyp void
und mit den beiden folgenden Parametertypen beherrscht:
• ? super T
Die Methode whenCompleteAsync() erhält das Ergebnis des angesprochenen
CompletableFuture<T> - Objekts. Der Parameter ist gleich null, wenn ...
o der angesprochene Verarbeitungsschritt den Rückgabetyp
CompletableFuture<Void> besitzt,
o wenn der angesprochene Verarbeitungsschritt oder ein vorausgesetzter Verarbei-
tungsschritt mit einer unbehandelten Ausnahme gescheitert ist.
• ? super Throwable
Die Methode whenCompleteAsync() erhält ggf. die Ausnahme, mit der ein vorausgesetzter
Verarbeitungsschritt gescheitert ist. Der Parameter ist gleich null, wenn der angesprochene
Verarbeitungsschritt erfolgreich ausgeführt worden ist.
Wenn der angesprochene Verarbeitungsschritt den Rückgabetyp CompletableFuture<Void> be-
sitzt und erfolgreich ausgeführt worden ist, dann sind beide Parameterobjekte gleich null.
Die von whenCompleteAsync() gelieferte Aufgabe reicht den Wert ihres Vorgängers, den sie z. B.
in einer Protokollausgabe verwenden kann, unverändert weiter. In der Regel steht diese Aufgabe
allerdings am Ende der Verarbeitungskette, sodass ihr Wert irrelevant ist.
Im folgenden Programm
Abschnitt 15.6 Aufgabenparallelität mit Hilfe der Klasse CompletableFuture<T> 839

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;
});

CompletableFuture<Integer> cf2 = cf1.thenApplyAsync(i -> {


System.out.println("Incrementer: " + Thread.currentThread().getName());
return ++i;
});

CompletableFuture<Integer> result = cf2.whenCompleteAsync((i, ex) -> {


if (ex != null) {
System.out.println(ex.getClass());
System.out.println(" Cause: " + ex.getCause().getClass().getName());
System.out.println(" Message: " + ex.getCause().getMessage());
}
System.out.println("Accepter: " + Thread.currentThread().getName());
System.out.println("Ergebnis = " + i);
});

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

15.6.5 Auf eine Serie von Aufgaben warten

15.6.5.1 Auf die Beendigung aller Aufgaben warten


Mit Hilfe der statischen CompletableFuture<T> - Methode allOf() kann man auf die erfolgreiche
Ausführung einer Serie von Aufgaben warten. Die Methode liefert ein CompletableFuture<Void>
- Objekt, das genau dann abgeschlossen ist, wenn alle Vorgängeraufgaben abgeschlossen sind:
840 Kapitel 15 Multithreading

public static CompletableFuture<Void> allOf(CompletableFuture<?>... cfs)


Im folgenden Programm werden parallel drei Aufgaben ausgeführt, auf die eine Ergebnisaufgabe
wartet. Für alle Aufgaben ist der Common Pool zuständig, der vor dem Programmende per
awaitQuiescence() aufgefordert wird, vor seiner automatischen Terminierung alle laufenden Auf-
gaben zu Ende zu bringen, sofern die erlaubte maximale Zeit nicht überschritten wird:
import java.util.List;
import java.util.concurrent.*;

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;
});

CompletableFuture<Integer> cf2 = CompletableFuture.supplyAsync(() -> {


System.out.println("Second: " + Thread.currentThread().getName());
try {Thread.sleep(1000);}
catch (InterruptedException ix) {Thread.currentThread().interrupt();}
return 2;
});

CompletableFuture<Integer> cf3 = CompletableFuture.supplyAsync(() -> {


System.out.println("Third: " + Thread.currentThread().getName());
try {Thread.sleep(1000);}
catch (InterruptedException ix) {Thread.currentThread().interrupt();}
if (false)
throw new IllegalStateException("Third task failed");
return 3;
});

CompletableFuture<Void> cfa = CompletableFuture.allOf(cf1, cf2, cf3);


cfa.whenCompleteAsync((i, ex) -> {
if (ex != null) {
System.out.println("\nTask series failed with exception:");
System.out.println(" Cause: " + ex.getCause().getClass().getName());
System.out.println(" Message: " + ex.getCause().getMessage());
}
for(var cf: List.of(cf1, cf2, cf3))
System.out.println(cf.join());
});

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

Task series failed with exception:


Cause: java.lang.IllegalStateException
Message: Third task failed
1
2

15.6.5.2 Auf die Beendigung eine beliebigen Aufgabe warten


Mit Hilfe der statischen CompletableFuture<T> - Methode anyOf() kann man warten, bis irgend-
eine Aufgabe aus einer Serie endet (erfolgreich oder mit einem Ausnahmefehler). Die Methode lie-
fert ein Objekt vom Typ CompletableFuture<Object>, das genau dann terminiert ist, wenn ir-
gendeine Vorgängeraufgabe terminiert ist:
public static CompletableFuture<Object> anyOf(CompletableFuture<?>... cfs)
Im folgenden Programm endet eine von drei parallel durch den Common Pool ausgeführten Aufga-
ben mit einer Ausnahme:
import java.util.concurrent.CompletableFuture;

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;
});

CompletableFuture<Integer> cf2 = CompletableFuture.supplyAsync(() -> {


try {Thread.sleep(100);}
catch (InterruptedException ix) {Thread.currentThread().interrupt();}
System.out.println("Second: " + Thread.currentThread().getName());
return 2;
});

CompletableFuture<Integer> cf3 = CompletableFuture.supplyAsync(() -> {


try {Thread.sleep(100);}
catch (InterruptedException ix) {Thread.currentThread().interrupt();}
System.out.println("Third: " + Thread.currentThread().getName());
if (true)
throw new IllegalStateException("Third task failed");
return 3;
});
842 Kapitel 15 Multithreading

CompletableFuture<Object> cfa = CompletableFuture.anyOf(cf1, cf2, cf3);


try {
System.out.println(cfa.join());
} catch (Exception ex) {
System.out.println("Exception\n Class: " + ex.getClass().getName());
System.out.println(" Cause: " + ex.getCause().getClass().getName());
System.out.println(" Message: " + ex.getCause().getMessage());
} finally {
System.out.println("Task: " + cfa);
}
}
}

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).

15.7 Thread-sichere Kollektionen im Paket java.util.concurrent


Die Klasse Collections im Paket java.util besitzt etliche statische Fabrikmethoden, die zu Klassen
aus dem Java Collections Framework (JCF) eine synchronisierte Verpackungsklasse liefern, z. B.:
• public static <T> List<T> synchronizedList(List<T> list)
• public static <T> Set<T> synchronizedSet(Set<T> set)
• public static <K,V> Map<K,V> synchronizedMap(Map<K,V> map)
Diese synchronisierten Kollektionen sind leicht zu erstellen, haben aber Schwächen:
Abschnitt 15.7 Thread-sichere Kollektionen im Paket java.util.concurrent 843

• Behinderung von Threads durch das einzige Sperrobjekt (schlechte Skalierbarkeit)


Es wird nur eine Sperre für das gesamte Kollektionsobjekt verwendet. Während ein Thread
im Besitz dieser Sperre ist, kann kein anderer Thread auf die Kollektion zugreifen. Auch ein
lesend zugreifender Thread blockiert alle anderen Threads, die entweder lesend oder schrei-
bend zugreifen wollen. Mit der Anzahl der am Zugriff interessierten Threads steigt die
Wahrscheinlichkeit dafür, dass Threads in der Bewerberwarteschlage für das eine Sperrob-
jekt aufgehalten werden, und die Performanz der Anwendung leidet. Das synchronisierte
Kollektionsobjekt sorgt als Flaschenhals für eine schlechte Skalierbarkeit der Anwendung
und eignet sich daher z. B. nicht für einen geteilten Cache (dt.: schnellen Zwischenspeicher)
in einer Server-Anwendung.
• Nur bedingte Thread-Sicherheit
Wird über die Elemente einer von Collections produzierten Verpackungsklasse iteriert,
dann muss eine manuelle (externe) Synchronisierung vorgenommen werden:1
It is imperative that the user manually synchronize on the returned collection when travers-
ing it via Iterator, Spliterator or Stream.
Die von Klassen im JCF implementierten Iteratoren sind fail-fast (siehe Abschnitt 10.5): Ist
die Kollektion nach der Erstellung des Iterators auf andere Weise als durch die remove() -
Methode des Iterators strukturell geändert worden, dann werfen die kritischen Methoden des
Iterators eine ConcurrentModificationException, um erratisches Verhalten zu verhindern.
Allerdings gelingt die Detektion schon im Singlethreading-Betrieb nicht zuverlässig, und im
Multithreading-Betrieb besteht ein großes Risiko, das durch die automatische Synchronisa-
tion nicht beseitigt wird. Weil die Thread-Sicherheit der von Collections produzierten Ver-
packungsklassen folglich nicht für alle Methoden gilt, spricht man hier von einer bedingten
Thread-Sicherheit.2
Das in Java 5 (alias 1.5) ergänzte Paket java.util.concurrent enthält u. a. Kollektionstypen, die für
Programme mit intensiver Parallelität besser geeignet sind als die automatisch synchronisierten
JCF-Kollektionen. Besonders populär ist die zur Verwaltung von (Schlüssel-Wert) - Paaren
geeignete Klasse ConcurrentHashMap<K,V> mit folgenden Lösungen für die beiden eben be-
schriebenen Probleme (Goetz 2003; Goetz et al. 2006, S. 85f):
• Statt ein Kollektions-globales Sperrobjekt zu verwenden, kommen bei der Klasse
ConcurrentHashMap<K,V> mehrere Sperrobjekte zum Einsatz, die jeweils mehrere
Buckets schützen. Als Ergebnis der insgesamt feiner granulierten Sperrtechnik ...
o sind simultane Lesezugriffe durch beliebig viele Threads erlaubt,
o sind gleichzeitige Schreibzugriffe durch eine Anzahl von Threads möglich,
o können lesende und schreibende Threads simultan agieren.
• Über die als keySet() - Rückgabe erhältliche Schlüsselmenge kann man iterieren, ohne die
Kollektion blockieren zu müssen, um eine ConcurrentModificationException zu verhin-
dern. Allerdings sollten nicht mehrere Threads gleichzeitig über die Kollektion iterieren. Der
Iterator zeigt den Zustand der Kollektion zu einem Zeitpunkt während oder nach seiner Er-
stellung. Es wird also nicht unbedingt über die Kollektion im aktuellen Zustand iteriert. Man
spricht hier von einem schwach konsistenten Iterator (engl.: weekly consistent iterator).
Das Design der Klasse ConcurrentHashMap<K,V> hat auch Nachteile, die aber in der Regel kei-
ne große Bedeutung haben:

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).

15.8 Threads und JavaFX


Wie die meisten modernen GUI-Frameworks ist auch JavaFX aus Performanzgründen nach dem
Singlethreading-Prinzip konzipiert. Daher muss der Zugriff auf die GUI-Komponenten von JavaFX
dem JavaFX Application Thread (alias: UI-Thread) vorbehalten bleiben.
Bei allen im JavaFX Application Thread ausgeführten Methoden muss sich der Zeitaufwand in
Grenzen halten (maximal 100 Millisekunden1), weil sonst die Bedienoberfläche zäh reagiert. Solan-
ge eine Methode läuft (z. B. gestartet als Reaktion auf ein Ereignis), kann die Anwendung nicht auf
andere Ereignisse (z. B. Mausklicks) reagieren. Auch ist keine Aktualisierung der Anzeige möglich,
zu der z. B. das Laufzeitsystem auffordert, weil ein bisher verdeckter Fensterbereich sichtbar ge-
worden ist.
Zeitaufwändige Arbeiten (z. B. Netzwerk- oder Datenbankzugriffe, aufwändige Berechnungen)
gehören in einen separaten Thread. In der Regel müssen aber irgendwann Ergebnisse der Hinter-
grundtätigkeit auf der Oberfläche sichtbar werden, wobei wegen der eingangs genannten Regel aus
dem separaten Thread keine direkten Zugriffe auf Steuerelemente möglich sind.

15.8.1 JavaFX-Komponenten aus einem Hintergrund-Thread modifizieren


Wir konstruieren eine Variante des Mehrzweckzählprogramms aus dem Abschnitt 13.8.1, um zu
demonstrieren, dass die Berechnung des Mittelwerts aus 100 Millionen pseudozufälligen double-
Zahlen in einem separaten Thread die Bedienbarkeit eines JavaFX-Programms nicht beeinträchtigt:

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

15.8.2 Das JavaFX-Multithreading - API


Mit den Typen im Paket javafx.concurrent kann man Aufgaben in separate Threads verlagern und
dabei den Status der Auftragsbearbeitung, den aktuellen Bearbeitungsfortschritt sowie die Ergebnis-
se im JavaFX Application Thread verwenden. Die wesentlichen Typen sind:
Abschnitt 15.8 Threads und JavaFX 847

• 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).

15.8.3 Die Klasse Task<V>


Das empfehlenswerte Verfahren zur Realisation einer einmalig auszuführenden Hintergrundaktivi-
tät in einem JavaFX-Programm besteht darin, von Task<V> eine eigene Klasse abzuleiten und mi-
nimal die Methode call() zu überschreiben. Wir modifizieren das Mehrzweckzählprogramm aus
dem Abschnitt 15.8.1 und demonstrieren, dass die Einbindung einer Hintergrundaktivität mit Hilfe
der Klasse Task<V> einige zusätzliche Optionen bietet:

U. a. wird es möglich, ...


• die im Abschnitt 15.8.2 beschriebenen JavaFX-Properties der Hintergrundaktivität im UI-
Thread zu nutzen (z. B. für eine Fortschrittsanzeige), weil die Klasse Task<V> das Interface
Worker<V> implementiert,
• bei der Hintergrundbearbeitung aufgetretene Ausnahmefehler an den JavaFX Application
Thread zu berichten,
• die Hintergrundaktivität geordnet abzubrechen.
Weil unsere Hintergrundaktivität als Ergebnis eine Double-Zahl liefert, verwenden wir die Basis-
klasse Task<Double>:
Abschnitt 15.8 Threads und JavaFX 849

private class RandomNumberCruncher extends Task<Double> {


@Override
protected Double call() {
double d = 0.0;
for (long i = 0; i < anz; i++) {
if (this.isCancelled()) {
this.updateMessage("Die Aufgabe wurde abgebrochen.");
break;
}
d += Math.random();
if ((i+1)%1_000_000 == 0)
this.updateMessage("Iteration: " + (i+1));
this.updateProgress(i, anz);
}
return d/anz;
}
@Override
protected void succeeded() {
super.succeeded();
this.updateMessage("Ergebnis: " + this.getValue());
}
}

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 Reaktive Ströme (Flow-API)


Netflix und andere Firmen mit Interesse an der performanten und zuverlässigen Weitergabe von
Daten ...
• von einem Publisher
• an einen oder an mehrere Subscriber
haben die Spezifikation der reaktiven Ströme 2015 veröffentlichet.1 Ein Subscriber informiert den
Publisher darüber, dass er n Elemente verarbeiten möchte, woraufhin der Publisher durch maximal
n Methodenaufrufe an den Subscriber (im sogenannten push-Betrieb) jeweils ein Element ausliefert.
Die Zuflusskontrolle durch den Subscriber wird als Gegendruck (engl. back pressure, oft zusammen
geschrieben: backpressure) bezeichnet und ist ein zentraler Bestandteil in der reactive streams -
Spezifikation (ab jetzt kurz als RS-Spezifikation bezeichnet).
Durch den Gegendruck wird verhindert, dass der Subscriber mehr Daten erhält, als er verarbeiten
kann, was zu unerwünschten Konsequenzen führen würde:
• Blockierung von Publisher-Threads (Leistungseinbuße)
• Pufferüberlauf beim Subscriber (Datenverlust oder Speichermangel)
• Ausnahmefehler
Der Überlastungsschutz soll erreicht werden, ohne auf der Subscriber-Seite beliebig große Daten-
mengen puffern zu müssen.
Die reaktiven Ströme haben trotz des gemeinsamen Namensbestanteils wenig Gemeinsamkeiten mit
...
• dem im Kapitel 14 (Ein- und Ausgabe über Datenströme) beschriebenen seriellen Lesen und
Schreiben von Bytes oder Zeichen,
• der im Abschnitt 12.2 (Ströme) beschriebenen Massenverarbeitung einer Serie von Objekten
durch API-Methoden, die Iterationen kapseln und nach Möglichkeit automatisch eine Paral-
lelverarbeitung basierend auf dem Fork-Join - Framework realisieren.
Von den im Netzwerkbereich (z.B. im TCP-Protokoll) realisierten Konzepten zur Datenflusskon-
trolle unterscheiden sich die reaktiven Ströme schon dadurch, dass die gesamte Kommunikation
innerhalb einer JVM stattfindet. Die Daten werden nicht zwischen verschiedenen Rechnern weiter-
gegeben, sondern z. B. zwischen verschiedenen Threadpools innerhalb einer JVM.
Bezüge zur Netzwerkprogrammierung bestehen aber bei der in Java 11 eingeführten Klasse
HttpClient (siehe Abschnitt 16.2.4). Objekte dieser Klasse bieten als Publisher Daten an, die be-
darfsgerecht von einem HTTP-Server beschafft werden. Hier pflanzt sich der JVM-interne Gegen-
druck durch die Subscriber auf das Netzwerkverhalten der HttpClient-Objekte fort.

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

Abschließend werden die Schnittstellen im Flow-API in syntaktischer und semantischen Hinsicht


beschrieben.

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

15.9.2 Zentrale Prinzipien der reaktiven Stromverarbeitung


In der folgenden Abbildung sind zentrale Prinzipien der reaktiven Stromverarbeitung dargestellt:1

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).

15.9.3 Klasse SubmissionPublisher<T>


Obwohl die Schnittstelle Flow.Publisher<T> nur eine Methode enthält, ist eine implementierende
Klasse wegen der zahlreichen, im Verlauf von Abschnitt 15.9.1 geschilderten Verhaltensanforde-
rungen nicht leicht zu erstellen. In vielen Fällen eignet sich die im Paket java.util.concurrent (Mo-
dul: java.base) enthaltene Klasse SubmissionPublisher<T> zum Einstieg in die RS-Technik. Man
kann sie ...
• direkt verwenden
SubmissionPublisher<T> implementiert die Schnittstelle Flow.Publisher<T>.
• als Basisklasse verwenden
Eine abgeleitete Klasse kann sich z. B. um die Produktion bzw. Beschaffung von Daten
kümmern und zu deren Veröffentlichung die von SubmissionPublisher<T> geerbten Me-
thoden verwenden.

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

• zur Komposition verwenden


Um die im Abschnitt 7.10.1 beschriebenen Risiken für eine abgeleitete Klasse zu vermei-
den, kann man ein Member-Objekt vom Typ SubmissionPublisher<T> verwenden. In der
neu erstellten Klasse müssen die Methoden der Schnittstelle Publisher<T> implementiert
werden, wobei aber in der Regel nur Aufrufe der entsprechenden Methoden des Member-
Objekts erforderlich sind.
Wer das gesamte Multithreading-Kapitel gelesen hat, rechnet sicher schon lange mit dem Hinweis
auf die Möglichkeit, mit dem Flow-API eine weitere Lösung für die Produzenten-Konsumenten -
Kooperation zu erstellen (vgl. die Abschnitte 15.2.2, 15.2.4 und 15.2.5.1).

15.9.3.1 Versorgung eines einzelnen Subscribers per submit()


Weil das zu illustrierende Flow-API nicht trivial ist, wird in den anschließend präsentierten Beispie-
len der Einfachheit halber auf Anwendungsnähe verzichtet. Wir definieren zunächst eine einfache
Subscriber-Klasse:
import java.util.concurrent.Flow;

public class DemoSubscriber implements Flow.Subscriber<Integer> {


private static int subCum;
private int subNo;
private Flow.Subscription subscription;

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());

System.out.println("Die Auslieferung beginnt.");


for (int i = 1; i <= 5; i++)
publisher.submit(i);

publisher.close();
System.out.println("Der Publisher ist geschlossen");

// Verhindert, dass sich der main-Thread an den Restarbeiten beteiligt:


Thread.sleep(100);
// Der Common-Threadpool erhält Zeit, um die Restarbeiten auszuführen:
ForkJoinPool.commonPool().awaitQuiescence(10, TimeUnit.SECONDS);
}
}
wird ein Objekt der Klasse SubmissionPublisher<Integer> definiert:
SubmissionPublisher<Integer> publisher = new SubmissionPublisher<>();
Bei Verwendung des parameterfreien Konstruktors erhält man einen Publisher, der ...
• zur Auslieferung seiner Nachrichten (z.B. für seine onNext() - Aufrufe) den Common Pool
des Fork-Join - Frameworks verwendet (siehe Abschnitt 15.5.1)
• mit der Puffergröße 256 arbeitet.
Offenbar können wir trotz Flow-API das Problem einer Überlastung und die Notwendigkeit einer
Pufferung nicht aus der Welt schaffen. Eine Abschnittsüberschrift in Grammes & Schal (2015) lau-
tet daher treffend: Stau an der richtigen Stelle mit Backpressure.
Per subscribe() wird eine DemoSubscriber-Instanz registriert. In einer for-Schleife wird durch
submit() - Aufrufe die Auslieferung von Integer-Objekten mit den Werten 1 bis 5 an alle registrier-
ten Subscriber angeordnet. Die tatsächliche Auslieferung, erfolgt durch asynchron ausgeführte
onNext() - Methodenaufrufe an diejenigen Subscriber, die zuvor eine Belieferung per request()
angefordert haben.
Erhält ein Publisher einen close() - Aufruf, dann informiert er alle Subscriber durch einen
onComplete() - Aufruf und stellt dann seine Tätigkeit ein. Alle mit submit() angestoßenen Liefe-
rungen werden aber noch ausgeführt. Das betrifft auch Subscriber, die erforderliche request() -
Anforderungen nach dem close() - Aufruf einliefern. Weil ein SubmissionPublisher<T> neben
dem Interface Publisher<T> auch das Interface AutoClosable implementiert, lässt sich der close()
- Aufruf durch eine try-with-resources - Anweisung automatisieren.
Durch die letzte Anweisung in der main() - Methode des Beispielprogramms wird der Common
Pool per awaitQuiescence() dazu aufgefordert, vor seiner automatischen Terminierung bei Beendi-
Abschnitt 15.9 Reaktive Ströme (Flow-API) 857

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.

15.9.3.2 Auslieferungsmethode offer() als Alternative zur blockierenden Methode submit()


Durch die Spezifikation der reaktiven Ströme wird eine asynchrone, nicht-blockierende Verarbei-
tung unter Verwendung von beschränkten Puffergrößen angestrebt. Dieses Ziel kann an einer feh-
lerhaften Implementation scheitern. Ein SubmissionSubscriber<T> - Objekt verwendet für jeden
Subscriber einen eigenen Puffer, weil bei der Versorgung von mehreren Subscribern mit unter-
schiedlichen Verarbeitungsraten zu rechnen ist. In der folgenden Konstruktor-Überladung wird ein
explizit erstellter Threadpool verwendet (vgl. Abschnitt 15.4.1) und die Puffergröße wird auf 2 fest-
gelegt:
ExecutorService es = Executors.newCachedThreadPool();
SubmissionPublisher<Integer> publisher = new SubmissionPublisher<>(es, 2);
Am Ende des folgenden Programms
import java.util.concurrent.*;

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());

System.out.println("Die Auslieferung beginnt.");


for (int i = 1; i <= 5; i++) {
publisher.submit(i);
}
publisher.close();
es.shutdown();
}
}

wird der von newCachedThreadPool() erstellten Threadpool aufgefordert, seine Tätigkeit geordnet
einzustellen, d. h.:
858 Kapitel 15 Multithreading

• Bereits angenommene Aufgaben werden ausgeführt.


• Es werden keine neuen Aufgaben angenommen.
Diese Maßnahme ist erforderlich, weil in dem explizit erstellten Pool (im Unterschied zum Fork-
Join - Common Pool) Benutzer-Threads tätig sind, die ein Programm über das Ende des main-
Threads hinaus aktiv halten (vgl. Abschnitt 15.4.1).
Im aktuellen Beispielprogramm muss der Publisher an zwei Subscriber
publisher.subscribe(new Subscriber());
publisher.subscribe(new Subscriber());
die Integer-Objekte 1 bis 5 ausliefern:
for (int i = 1; i <= 5; i++)
publisher.submit(i);
Aufgrund der folgenden onNext() - Methode fordert nur der erste Subscriber nach dem Erhalt des
ersten Elements noch eine weitere Lieferung an:
@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;}
if (subNo == 1)
subscription.request(1);
}
Der zweite Subscriber fordert weder per request() - Aufruf eine weitere Lieferung an, noch termi-
niert er sein Subscription-Objekt durch einen cancel() - Aufruf. Folglich ist der Puffer dieses Sub-
scribers bald gefüllt, und die Methode submit() blockiert:
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 1 hat erhalten: 1 (Thread: pool-1-thread-1)
Subscriber 2 hat erhalten: 1 (Thread: pool-1-thread-2)
Subscriber 1 hat erhalten: 2 (Thread: pool-1-thread-1)
Subscriber 1 hat erhalten: 3 (Thread: pool-1-thread-1)
Subscriber 1 hat erhalten: 4 (Thread: pool-1-thread-1)

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 Sonstige Thread-Themen

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.

15.11 Übungsaufgaben zum Kapitel 15


1) Welche der folgenden Aussagen sind richtig bzw. falsch?
1. Ein Java-Programm endet zusammen mit dem Thread main.
2. Ereignisbehandlungsmethoden laufen im UI-Thread (JavaFX) und sollten nach spätestens
100 Millisekunden beendet sein.
3. Ein terminierter Thread kann nicht mehr neu gestartet werden.
4. Ein Thread im Zustand sleeping gibt alle Monitore und Lock-Objekte zurück.
Abschnitt 15.11 Übungsaufgaben zum Kapitel 15 861

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

Trotz H2M-Eignung lassen sich Webanwendungen auch durch Klientenprogramme anspre-


chen, was wir im Abschnitt 16.2 tun werden. Die so bezogenen HTML-Seiten können zwar
per Software interpretiert werden, sind aber nicht ideal für den Datentransfer zwischen Pro-
grammen.
Webdienste und Webanwendungen werden der Java Enterprise Edition (vgl. Abschnitt 1.3.4) oder
vergleichbaren Frameworks (z. B. Spring) zugerechnet, liegen also jenseits des Themenspektrums
einer Einführung in die Programmiersprache Java.

16.1 Elementare Konzepte der Netzwerktechnologie


Als Netzwerk bezeichnet man eine Anzahl von Systemen (z.B. Rechnern), die über ein gemeinsa-
mes Medium (z.B. Ethernet-Kabel, WLAN, Infrarotkanal) verbunden sind und über ein gemein-
sames Protokoll (z.B. TCP/IP) Daten austauschen können.
Unter einem Protokoll ist eine Menge von Regeln zu verstehen, die für eine erfolgreiche Kommu-
nikation von allen beteiligten Systemen eingehalten werden müssen.
Zwischen zwei Kommunikationspartnern jeweils eine reservierte Leitung (temporär) zu schalten
und auch in „Funkpausen“ aufrecht zu erhalten, wäre unökonomisch. Bei den meisten aktuellen
Netzwerkprotokollen werden Datenpakete mit Adressierung übertragen, was die gemeinsame
Verwendung eines Verbindungswegs für mehrere, simultan ablaufende Kommunikationsprozesse
ermöglicht. Dabei sind Vermittlungsstationen (Router) für die korrekte Weiterleitung der Pakete
zuständig, z. B.:
D

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.

16.1.1 Das OSI-Modell


Nach dem OSI - Modell (Open System Interconnection) der ISO (International Standards Organi-
sation) werden bei der Kommunikation über Netzwerke sieben aufeinander aufbauende Schichten
(engl.: layers) mit jeweiligen Zuständigkeiten und zugehörigen Protokollen unterschieden. Bei der
anschließenden Beschreibung dieser Schichten sollen wichtige Begriffe und vor allem die heute
üblichen Internet-Protokolle (z. B. IP, TCP, UDP, ICMP, HTTP) eingeordnet werden.
Abschnitt 16.1 Elementare Konzepte der Netzwerktechnologie 865

1. Physische Ebene (Bit-Übertragung, z. B. über Kupferdrahtleitungen)


Hier wird festgelegt, wie von der Netzwerk-Hardware Bits zwischen zwei direkt verbundenen Sta-
tionen zu übertragen sind. Im einfachen Beispiel einer seriellen Verbindung über Kupferkabel wird
z. B. festgelegt, dass zur Übertragung einer 0 eine bestimmte Spannung während einer festgelegten
Zeit angelegt wird, während eine 1 durch eine gleichlange Phase der Spannungsfreiheit ausgedrückt
wird.

2. Link-Ebene (Gesicherte Übertragung von Datenpaketen, z. B. per Ethernet-Protokoll)


Hier wird vereinbart, wie zwischen zwei Stationen, die sich im selben Subnetz (einer Rundruf-
Domäne) befinden oder direkt verbunden sind, ein Datenpaket zu übertragen ist, das aus einer An-
zahl von Bits besteht und durch eine Prüfsumme gesichert ist. Die Datenpakete werden auch als
Frames bezeichnet und sind von der Übertragungstechnik abhängig. In der Regel gehören zum Pro-
tokoll dieser Ebene auch Start- und Endmarkierungen, damit sich die beteiligten Geräte rechtzeitig
auf eine Informationsübertragung einstellen können.
Im einfachen Beispiel einer seriellen Datenübertragung zwischen direkt verbundenen Stationen über
das betagte RS-232 - Protokoll wird der folgende Frame-Aufbau verwendet:
Startbit: 0
8 Datenbits 0 oder 1
Prüfbit: odd (siehe unten)
Stoppbit: 1
In der folgenden Abbildung sind drei RS-232 - Frames zu sehen, die nacheinander über eine serielle
Leitung gesendet werden:
Zeit
0 1 0 0 0 1 1 1 1 0 1 0 0 1 0 0 1 0 0 0 1 1 0 1 1 0 0 0 0 1 1 1 1

Daten Daten Daten


0 Startbit Prüfbit (odd) 1 Stoppbit
Das odd-Prüfbit wird so gesetzt, dass es die acht Datenbits zu einer ungeraden Summe ergänzt.
Bei einem Ethernet-Frame ist der Aufbau etwas komplizierter, weil die Kommunikation in einem
Subnetz (in einer Rundruf-Domäne) unterstützt wird und folglich eine Adressierung erforderlich ist
(siehe z. B. Spurgeon 2000, S. 40ff):
• Es ist ein Header vorhanden, der u. a. die MAC-Adressen (Media Access Control) von Sen-
der und Empfänger enthält. Diese Level-2 - Adressen sind nur für die Subnetz-interne
Kommunikation relevant.
• Es können Daten im Umfang von 46 bis 1500 Bytes transportiert werden.

3. Netzwerkebene (Übertragung von Informationspaketen, z. B. per IP-Protokoll)


Die Datenpakete (Frames) der eben behandelten zweiten Ebene hängen von der verwendeten Netz-
werktechnik ab, sodass auf der Strecke vom Absender bis zum Empfänger in der Regel mehrere
Frame-Architekturen beteiligt sind (z. B. bei der WLAN-Verbindung zum hausinternen Router eine
andere als auf der Kupferkabelstrecke zum DSL-Provider). Auf der dritten Ebene kommen hinge-
gen Informationspakete zum Einsatz, die auf der gesamten Strecke (im Intra- und/oder im Inter-
net) unverändert bleiben und beim Wechsel der Netzwerktechnik in verschiedene Schicht 2 - Fra-
mes umgeladen werden (siehe Abschnitt 16.1.2).
866 Kapitel 16 Netzwerkprogrammierung

Durch die Protokolle der Schicht 3 sind u. a. folgende Aufgaben zu erfüllen:


• Adressierung (über Subnetzgrenzen hinweg)
Jedes Paket enthält eine Absender- und eine Zieladresse mit globaler Gültigkeit (über Sub-
netzgrenzen hinweg).
• Routing
In komplexen (und ausfallsicheren) Netzen führen mehrere Wege vom Absender eines Pa-
kets zum Ziel. Vermittlungsrechner (sog. Router) entscheiden darüber, welchen Weg ein
Paket nehmen soll.
• Datenflusskontrolle
Eine weitere Aufgabe der dritten Protokollebene besteht in der Datenflusskontrolle, wobei
der Empfänger zur Vermeidung einer Überlastung dem Sender signalisiert, in welcher Ge-
schwindigkeit er Pakete senden darf.
In der aktuellen Netzwerktechnik kommt auf der Ebene 3 überwiegend das IP-Protokoll zum Ein-
satz. Seine Pakete bezeichnet man auch als IP-Datagramme. In der älteren, immer noch verbreite-
ten IP-Version 4 (IPv4) besteht eine Adresse aus 32 Bits, die üblicherweise durch vier per Punkt
getrennte Dezimalzahlen (aus dem Bereich von 0 bis 255) dargestellt werden, z. B.:
192.168.178.12
Bei der modernen IP-Version 6 (IPv6) besteht eine Adresse aus 128 Bits, welche durch acht per
Doppelpunkt getrennte Blöcke mit jeweils vier Hexadezimalziffern dargestellt werden, z. B.:
2001:08c7:c79c:0000:0000:0000:88c7:0091
Innerhalb eines Blocks dürfen führende Nullen weggelassen werden, z. B.:
2001:8c7:c79c:0:0:0:88c7:91
Eine Gruppe aufeinanderfolgender Blöcke mit dem Wert 0000 bzw. 0 darf durch zwei Doppelpunk-
te ersetzt werden, z. B.:
2001:8c7:c79c::88c7:91
Nach dieser Regel kann die reservierte IPv6-Adresse des lokalen Rechners
0:0:0:0:0:0:0:1
sehr kurz geschrieben werden:
::1
Der OSI-Ebene 3 wird auch das Internet Control Message Protocol (ICMP) zugerechnet, das zur
Übermittlung von Fehlermeldungen und verwandten Informationen dient. Wenn z. B. ein Router
ein IP-Datagramm verwerfen muss, weil seine Maximalzahl von Weiterleitungen (Time To Live,
TTL) erreicht wurde, dann schickt er in der Regel eine Time Exceeded - Meldung an den Absender.
Auch die von ping - Anwendungen versandten Echo Requests und die zugehörigen Antworten zäh-
len zu den ICMP - Nachrichten.

4. Transportschicht (Gesicherte Paketübertragung, z. B. per TCP-Protokoll)


Zwar bemüht sich die Protokollebene 3 darum, Pakete auf möglichst schnellem Weg vom Absender
zum Ziel zu befördern, sie kann jedoch nicht garantieren, dass alle Pakete in korrekter Reihenfolge
ankommen. Dafür sind die Protokolle der Transportschicht zuständig, wobei momentan vor allem
das Transmission Control Protocol (TCP) zum Einsatz kommt. Das TCP wiederholt z. B. die
Übertragung von Paketen, wenn innerhalb einer festgelegten Zeit keine Bestätigung des Empfän-
gers beim Absender eingetroffen ist.
Abschnitt 16.1 Elementare Konzepte der Netzwerktechnologie 867

5. Sitzungsebene (Übertragung von Byte-Strömen zwischen Anwendungen, z. B. per TCP)


Auf dieser Ebene sind Regeln angesiedelt, die den Datenaustausch zwischen zwei Anwendungen
(meist auf verschiedenen Rechnern) ermöglichen. Auch solche Aufgaben werden in der heute übli-
chen Praxis vom Transmission Control Protocol (TCP) abgedeckt, das folglich für die OSI-Schich-
ten 4 und 5 zuständig ist.
Damit eine Anwendung auf Rechner A mit einer Anwendung auf Rechner B kommunizieren kann,
werden sogenannte Ports verwendet. Hierbei handelt es sich um Zahlen zwischen 0 und 65535 (216
- 1), die eine kommunikationswillige bzw. -fähige Anwendung auf einem Rechner identifizieren. So
wird es z. B. möglich, auf einem Rechner verschiedene Server-Programme zu installieren, die trotz-
dem von Klienten aufgrund ihrer verschiedenen Ports (z. B. 25 für einen SMTP-Server, 80 für einen
WWW-Server) gezielt angesprochen werden können. Die Ports ...1
• von 0 bis 1023 sind für Standarddienste reserviert (system ports, well-known ports).
• von 1024 bis 49151 werden von der IANA (Internet Assigned Numbers Authority) verwal-
tet.
• von 49152 bis 65535 (im sogenannten dynamischen Port-Bereich) sind für lokale Zwecke
und zur temporären Verwendung durch Klientenprogramme vorgesehen.
Eine TCP-Verbindung ist also bestimmt durch:
• Die IP-Adresse des Serverrechners und die Portnummer des Dienstes
• Die IP-Adresse des Klientenrechners und die dem Klientenprogramm vom Betriebssystem
zugeteilte Portnummer
Das TCP-Protokoll stellt eine virtuelle Verbindung zwischen zwei Anwendungen her. Auf beiden
Seiten steht eine als Socket (dt.: Steckdose) bezeichnete Programmierschnittstelle zur Verfügung.
Die beiden Sockets kommunizieren über Datenströme miteinander. Aus der Sicht des Anwen-
dungsprogrammierers werden per TCP keine Pakete übertragen, sondern Ströme von Bytes (zur
Kommunikation über Datenströme siehe Kapitel 14). Auf einem Rechner können durchaus mehrere
Programme per Socket-Technik mit Programmen auf anderen Rechnern (oder auch auf demselben
Rechner) kommunizieren, wobei der Rechner trotzdem nur eine physische Netzwerksteckdose be-
nötigt.
Von den Internet-Protokollen ist auch das User Datagram Protocol (UDP) auf der Ebene 5 einzu-
ordnen. Es sorgt ebenfalls für eine Kommunikation zwischen Anwendungen und nutzt dazu Ports
wie das TCP. Allerdings sind die Ports praktisch die einzige Erweiterung gegenüber der IP-Ebene.
Es handelt sich also um einen ungesicherten Paketversandt ohne Garantie für eine vollständige Aus-
lieferung in korrekter Reihenfolge. Aufgrund der somit eingesparten Verwaltungskosten eignet sich
das UDP zur Übertragung größerer Datenmengen, wenn dabei der Verlust einzelner Pakete zu ver-
schmerzen ist (z. B. beim Multimedia - Streaming).

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

7. Anwendung (Protokolle für Endbenutzer-Dienstleistungen, z. B. HTTP(S))


Hier wird für verschiedene Dienste festgelegt, wie Anforderungen zu formulieren und Antworten
auszuliefern sind. Als Beispiel betrachten wir die Kommunikation zwischen einem Klientenpro-
gramm, das eine Mail versenden möchte, und einem SMTP-Server (Simple Mail Transfer Proto-
col), der an Port 25 lauscht. Zur Kommunikation mit einem SMTP-Server eignet sich z. B. ein Tel-
net-Klient, wobei unter Windows 10 eine Aktivierung erforderlich ist:
• Im Suchfeld Systemsteuerung eingeben und das gefundene Programm starten
• Aus der Liste mit den kleinen Symbolen die Programme und Features wählen
• Klick auf Windows-Features aktivieren oder deaktivieren
• Telnet-Client markieren und quittieren:

Um mit dem SMTP-Server smtp.srv-dom.de zu kommunizieren, startet man den Telnet-Klienten


in einem Konsolenfenster über das folgende Kommando:
> telnet smtp.srv-dom.de 25
Nach der Antwort des SMTP-Servers
220 srv.srv-dom.de SMTP MAIL Service ready at Wed, 7 Jul 2021 04:23:41 +0200
kann man z. B. folgendermaßen eine Mail übergeben:

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.

16.1.2 Zur Funktionsweise von Protokollstapeln


Möchte eine Anwendung auf dem Rechner A über ein TCP/IP - Netzwerk eine gemäß zugehörigem
Anwendungsebenen-Protokoll (z. B. SMTP) zusammengestellte Sendung an eine korrespondieren-
de Anwendung auf dem Rechner B schicken, dann übergibt sie eine Serie von Bytes an die TCP-
Schicht des Rechners A, welche daraus TCP-Pakete erstellt. Wir beschränken uns auf den einfachen
Fall, dass alle Daten in ein TCP-Paket passen, und machen die analoge Annahme auch für alle wei-
teren Neuverpackungen:
870 Kapitel 16 Netzwerkprogrammierung

Daten in einem Ausgabe-Bytestrom der Anwendungsebene Daten



TCP-Paket auf der Transport- bzw. Sitzungsebene TCP-Header Daten

IP-Paket auf der Netzwerkebene IP-Header TCP-Paket

Ethernet-Frame Ethernet-Header IP-Paket

Wichtige Bestandteile des TCP-Headers sind:


• Die Portnummern der Quell- und Zielanwendung
• TCP-Flags
Hierzu gehört z. B. das zur Gewährleistung der Auslieferung von TCP-Paketen benutzte
ACK-Bit. Weil es bei allen Paketen einer Verbindung mit Ausnahme des initialen Pakets
gesetzt ist, kann z. B. eine Firewall-Software an diesem Bit erkennen, ob ein von außen ein-
treffendes Paket zur (unerwünschten) Verbindungsaufnahme dienen soll.
Das TCP-Paket wird weiter „nach unten“ durchgereicht zur IP-Schicht, die ihren eigenen Header
ergänzt, der u. a. folgende Informationen enthält:
• Die IP-Adressen von Quell- und Zielrechner
• Typ des eingepackten Protokolls (z. B. TCP oder UDP)
• Time-To-Live (TTL)
Beim Routing kann es zu Schleifen kommen. Damit ein Paket nicht ewig rotiert, startet es
mit einer Time-To-Live - Angabe mit der maximalen Anzahl von erlaubten Router-
Passagen, die von jedem Router dekrementiert wird. Muss ein Router den TTL-Wert auf
null setzen, verwirft er das Paket und informiert den Absender eventuell per ICMP über den
Vorfall.
Wenn das erste Etappenziel auf dem Weg zum Rechner B der per Ethernet-Technik erreichbare
Router im Subnetz von Rechner A ist, dann muss das IP-Paket in einen Ethernet-Frame verpackt
werden, wobei der Ethernet-Header u. a. die MAC-Adressen von Sender und Empfänger enthält.
Auf das erste Etappenziel (den ersten Router) folgen weitere, die über Nahverkehrsstrecken (z.B.
Ethernet) und/oder Fernverkehrsstrecken (z.B. Glasfaser, DSL) erreichbar sind.
Auf dem Rechner B wird der umgekehrte Weg durchlaufen: Jede Schicht entfernt ihren eigenen
Header und reicht den Inhalt an die nächsthöhere Ebene weiter, bis die übertragenen Daten schließ-
lich in einem Eingabestrom der zuständigen Anwendung (identifiziert über die Portnummer im
TCP-Header) angekommen sind.

16.1.3 Optionen zur Netzwerkprogrammierung in Java


Java unterstützt sowohl die Socket-orientierte TCP- bzw. UDP-Kommunikation (auf der Ebene 5
des OSI-Modells) als auch die Nutzung wichtiger Protokolle auf der Anwendungsebene (z.B.
HTTP(S), SMTP). Ein Zugriff auf tiefere Protokollschichten ist nur über externe, per JNI (Java
Native Interface) angebundene Software möglich, was allerdings bei Netzwerkprogrammen selten
erforderlich ist. Die zur Netzwerkprogrammierung in Java erforderlichen API-Klassen befinden
sich meist im Paket java.net, das seit Java 9 zum Modul java.base gehört.
Abschnitt 16.2 Internet-Ressourcen per HTTP-Protokoll nutzen 871

Wir beschäftigen uns im Manuskript mit den folgenden Aufgaben:


• Internet-Ressourcen (z. B. WWW-Seiten) per HTTP - Protokoll nutzen
Dabei spielen die Klassen URL und UrlConnection aus dem Paket java.net sowie die be-
deutend jüngere Klasse HttpClient aus dem Paket java.net.http eine zentrale Rolle.
• Netzwerkkommunikation auf Socket-Ebene
Dabei beschränken wir uns auf das TCP-Protokoll, verzichten also auf das nur für wenige
Anwendungen relevante UDP-Protokoll.
Bei beiden Aufgaben werden die im Kapitel 14 beschriebenen Stromverarbeitungsklassen intensiv
genutzt, weil zwischen Dateien einerseits und Netzwerkverbindungen ab der OSI-Ebene 5 (bzw. bei
einer etablierten TCP-Verbindung) andererseits hinsichtlich der Datenein- und Ausgabe viele Ge-
meinsamkeiten bestehen.
• Beim Zugriff auf Internet-Ressourcen (WWW-Seiten, Dateien) mit Hilfe der traditionellen
Klassen URL und UrlConnection fordert man ein InputStream-Objekt an, auf dem
Stromverarbeitungsklassen für höhere Datentypen aufsetzen können (z. B. BufferedReader
oder Scanner).
• Bei der Netzwerkkommunikation auf Socket-Ebene verwenden die beiden beteiligten Pro-
gramme jeweils ein vom Socket geliefertes InputStream- und ein OutputStream-Objekt,
wobei das OutputStream-Objekt des ersten Programms mit dem InputStream-Objekt des
zweiten Programms verbunden ist.

16.2 Internet-Ressourcen per HTTP-Protokoll nutzen


Obwohl im Internet die Interaktivität im Vordergrund steht, sind vielfach automatisierte Routinezu-
griffe auf Webangebote per Programm von Nutzen (z.B. Abrufen von Wetterdaten, Abholen der
monatlichen Provider-Rechnung, Download der aktuellen Signaturdatei einer Schutz-Software). In
einem Artikel des Computer-Magazins c't (Ausgabe 04/2010) mit dem Titel „Persönliche Webro-
boter“ werden entsprechende Lösungen in Skriptsprachen wie Perl, Ruby oder PowerShell
beschrieben. Wer die Programmiersprache Java beherrscht, findet im Standard-API und weiteren
Bibliotheken sehr gute Voraussetzungen zum Erstellen von „persönlichen Webrobotern“.
Im Abschnitt 16.2.2 werden traditionsreiche API-Klassen behandelt, die schon in der Java-Version
1.0 bzw. 1.1 vorhanden waren und dementsprechend eine große Verbreitung besitzen, aber die ak-
tuelle Version 2 des HTTP-Protokolls nicht nutzen können.
Am Abschnitt 16.2.4 wird das mit Java 11 eingeführte HTTP/2 - API vorgestellt. Seine Typen un-
terstützen nicht nur die aktuelle Version des HTTP-Protokolls, sondern sind auch gut für die asyn-
chrone Verwendung vorbereitet. Wenn nicht sehr einfache Aufgaben zu bewältigen oder vorhande-
ne Lösungen zu pflegen sind, dann sollte das moderne API aus Java 11 bevorzugt werden.
Die vorgestellten Lösungen übernehmen zwar die Klientenrolle im HTTP-Protokoll, sind aber nicht
nur in Desktop-Programmen zu gebrauchen. Auch eine Server-Software übernimmt gelegentlich die
Klientenrolle, um Informationen von einem HTTP-Server zu beschaffen.

16.2.1 Uniform Resource Locator


Auf Internet-Ressourcen, die über einen so genannten Uniform Resource Locator (URL) ansprech-
bar sind, kann man in Java fast genauso einfach zugreifen wie auf lokale Dateien.
872 Kapitel 16 Netzwerkprogrammierung

Ein URL wie z. B.


https://www.egal.de:81/cgi/beispiel/cgi.php?vorname=Kurt
ist folgendermaßen aufgebaut:
Syntax: Proto- :// User:Pass@ Domänen- :Port Pfad ?URL-Parameter
koll (optional) name (optional) (optional)
Beispiel: https :// www.egal.de :81 /cgi/beispiel/cgi.php ?vorname=Kurt

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 HTTP/1.1 - API


Die im aktuellen Abschnitt vorgestellten Klassen sind bereits seit der Java-Version 1.0 oder 1.1
vorhanden, können also auch in Programmen genutzt werden, die sich auf Java 8 beschränken.

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:

Hält man das Programm z. B. über die Thread-Methode sleep()


try {Thread.sleep(60000);} catch (Exception x) {}
nach der Nutzung des Eingabestroms aktiv, dann wirkt sich das Schließen des Stroms aus, sofern
die Antwort eines angesprochenen HTTP-Servers noch nicht vollständig abgerufen worden ist:
• Ohne das Schließen des Stroms bleibt die Verbindung bis zum Programmende bestehen:

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);

try (BufferedInputStream in = new BufferedInputStream(url.openStream());


BufferedOutputStream out = new BufferedOutputStream(
new FileOutputStream((new File(url.getPath()).getName())))) {
System.out.println("Die Datei " + urlString + " wird heruntergeladen ...");
int i, n = 0;
while ((i = in.read()) != -1) {
out.write(i);
n++;
}
System.out.println("Fertig! (Bytes geschrieben: " + n + ")");
}
} catch (Exception e) {

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";

URL url = new URL(urlString);


URLConnection urlConn = url.openConnection();
Abschnitt 16.2 Internet-Ressourcen per HTTP-Protokoll nutzen 877

SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");


Date date = sdf.parse("2022-04-16 00:00:00");
urlConn.setIfModifiedSince(date.getTime());

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())));

System.out.println("\nWeiter zum Inhalt mit Enter");


System.in.read();
try (BufferedReader br = new BufferedReader(
new InputStreamReader(urlConn.getInputStream()))) {
String zeile;
while ((zeile = br.readLine()) != null)
System.out.println(zeile);
}
} catch (Exception e) {
e.printStackTrace();
}
}
}

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

Die von HttpURLConnection abgeleitete Klasse HttpsURLConnection im Paket javax.net.ssl


unterstützt zusätzlich die Kommunikation mit der sicheren (verschlüsselnden) Protokollvariante
HTTPS.
Bei passendem Protokoll in der Ressourcen-Beschreibung liefert die URL-Methode
openConnection() ohnehin ein HttpsURLConnection-Objekt, so dass nur noch eine Typumwand-
lung erforderlich ist, um die erweiterte Funktionalität zu erschließen, z. B.:1
URL url = new URL("https://www.uni-trier.de/");
URLConnection urlConn = url.openConnection();
urlConn.connect();
if (urlConn instanceof HttpsURLConnection) {
HttpsURLConnection huc = (HttpsURLConnection) urlConn;
. . .
}
In der folgenden Anweisung wird der Statuscode einer Webserver-Antwort samt Erläuterung ermit-
telt:
System.out.println("\nRequest-Status:\n Code:\t\t" + huc.getResponseCode() +
"\n Message:\t" + huc.getResponseMessage());
Hat alles geklappt, dann erhält man den Code 200:
Request-Status:
Code: 200
Message: OK
Den folgenden Request-Status haben Sie vermutlich schon öfter gesehen:
Request-Status:
Code: 404
Message: Not Found

Zu der Frage, ob man einen durch die HttpURLConnection-Methode getInputStream() erhalte-


nen Netzwerkeingabestrom (oder einen durch die Methode getOutputStream() erhaltenen Netz-
werkausgabestrom) schließen bzw. die involvierte Netzwerkverbindung beenden sollte, äußert sich
die API-Dokumentation zu Java 17 so:2
Calling the close() methods on the InputStream or OutputStream of an HttpURLConnection after
a request may free network resources associated with this instance but has no effect on any
shared persistent connection. Calling the disconnect() method may close the underlying socket if
a persistent connection is otherwise idle at that time.
Nach der Erledigung einer Anfrage sollten die beteiligten Netzwerkströme geschlossen werden, was
am einfachsten über die mit Java 7 eingeführte automatische Ressourcenfreigabe geschieht (siehe
Abschnitt 11.10.2).
Wie schon im Zusammenhang mit der Klasse URL im Abschnitt 16.2.2.1 erläutert wurde, ermög-
licht die Version 1.1 des HTTP-Protokolls mehrfache Request/Response - Paare unter Verwendung
derselben TCP-Verbindung, um die Performanz zu fördern und den Netzwerkverkehr zu begrenzen.
Auf einen abgeschlossenen Request/Response - Austausch folgt eine wenige Sekunden dauernde
Keep-Alive - Periode. Durch einen Aufruf der HttpURLConnection-Methode disconnect() signa-
lisiert man, dass die TCP-Verbindung beendet werden soll, weil keine weiteren Anforderungen an
denselben Server vorgesehen sind. In der Regel kann man auf einen Aufruf dieser Methode verzich-
ten.

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 Dynamisch erstellte Webinhalte anfordern


Im bisherigen Verlauf von Abschnitt 16.2 haben wir statische Webangebote (HTML-Seiten oder
Dateien) durch Java-Programme angefordert. Nun beschäftigen wir uns mit dem Abruf von dyna-
misch erstellten Webseiten durch Java-Programme. Wie zu Beginn von Kapitel 16 angekündigt, ist
es aus Zeitgründen leider nicht möglich, die Realisation von dynamischen Webangeboten durch
serverseitige Java-Programme zu behandeln.
Im aktuellen Abschnitt beschränken wir uns auf die Klassen aus dem HTTP/1.1 - API von Java
(URL, HttpsURLConnection). Später werden aber auch Lösungen unter Verwendung des HTTP/2
- APIs aus Java 11 vorgestellt (HttpClient).

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

ist eine HTML-Seite zu sehen, die über den URL


https://urtkurs.uni-trier.de/prokur/netz/cgig.html
Abschnitt 16.2 Internet-Ressourcen per HTTP-Protokoll nutzen 881

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&szlig;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

Durch die statische Methode encode() der Klasse URLEncoder


public static String encode(String s, String enc) throws UnsupportedEncodingException
werden die GET-Parameter in das Format application/x-www-form-urlencoded gebracht:
• Es wird die vom HTTP-Server benötigte Codierung eingestellt (meist: UTF-8).
• Leerzeichen werden durch ein + ersetzt.
• Für die mit einer speziellen Bedeutung belasteten Zeichen (&, +, = und %) erscheint nach
einem einleitenden Prozentzeichen ihr Hexadezimalwert im gewählten Zeichensatz, z. B.
%26 für &.
Wir werden später kurz von der URL-Codierung sprechen.
Über die URL-Methode openStream() erhält man ein Eingabestromobjekt vom zugrundeliegenden
URLConnection-Objekt. Der Aufruf
url.openStream()
ist äquivalent zu expliziten Variante
url.openConnection().getInputStream()
Durch die Verwendung einer try-with-resources - Anweisung wird der Netzwerk-Eingabestrom
geschlossen (siehe Abschnitt 16.2.2.2 zum Effekt dieser Maßnahme).
Weil das Programm die vom CGI-Skript gelieferte HTML-Seite nur als unformatierten Text dar-
stellt, ist sein Auftritt nicht berauschend:
Vorname: Kurt
Nachname: Müller

<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.01 Transitional//DE">


<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>
Im Abschnitt 16.2.5 wird die formatierte Darstellung von HTML-Seiten im Rahmen von JavaFX-
Programmen unter Verwendung der Klasse WebView vorgestellt.

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();

URL url = new URL("https://urtkurs.uni-trier.de/prokur/netz/cgip.php");


HttpsURLConnection urlConn = (HttpsURLConnection) url.openConnection();
urlConn.setDoOutput(true);
try (PrintWriter pw = new PrintWriter(urlConn.getOutputStream())) {
pw.print("vorname=" + URLEncoder.encode(vorname, "UTF-8") +
"&nachname=" + URLEncoder.encode(nachname, "UTF-8"));
}

try (BufferedReader br = new BufferedReader(


new InputStreamReader(urlConn.getInputStream()))) {
String zeile;
while ((zeile = br.readLine()) != null)
System.out.println(zeile);
}
} catch (Exception e) {
e.printStackTrace();
}
}
}

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

16.2.4 HTTP/2 - API


In Java 11 wurde die Standardbibliothek um Typen erweitert, die den Zugriff auf Internet-
Ressourcen über das HTTP-Protokoll u. a. aus den folgenden Gründen verbessern:
• Vom HTTP-Protokoll wird neben der aus 1999 stammenden Version 1.1 auch die aktuelle,
2015 eingeführte Version 2.0 unterstützt, die mehrere Techniken zur Steigerung der Perfor-
manz bietet, z. B.:
o Binäres (und folglich kompaktes) Datenformat
o Multiplexing
Ein Klient kann nach einer Anforderung weitere abschicken, ohne die Antworten auf
die bisherigen Anforderungen abwarten zu müssen.
Die Klassen im HTTP/2 - API von Java 11 bevorzugen die aktuelle HTTP-Version, greifen
nötigenfalls aber automatisch auf die Version 1.1 zurück.
• Die traditionellen Klassen zur Nutzung des HTTP-Protokolls stellen dem Programmierer
zum Versandt von POST-Parametern bzw. zum Empfang von Server-Antworten die im Ka-
pitel 14 beschriebenen Datenstromtechnik (mit den Basisklassen OutputStream und In-
putStream aus dem Paket java.io) zur Verfügung. Diese von manchen Autoren als Low-
Level-Abstraktion kritisierte Lösung (siehe z.B. Inden 2018b) ist im neuen API durch
HTTP-spezifische Methoden ersetzt.
• Im neuen API sind synchrone (blockierende) und asynchrone Methoden vorhanden. Bei der
asynchronen Verarbeitung kommt die im Abschnitt 15.6 beschriebene Klasse
CompletableFuture<T> zum Einsatz, sodass es insbesondere möglich ist, HTTP-
Anforderungen in einen Verarbeitungsprozess mit einem hohen Anteil an Parallelität zu in-
tegrieren.
• Bei der Verarbeitung der Request- und Response-Inhalte werden die mit Java 9 eingeführten
reaktiven Ströme verwendet, sodass die jeweiligen Subscriber die angelieferte Datenmenge
steuern und eine Überflutung vermeiden können. Die reaktiven Ströme sind von den Daten-
strömen im Sinn Kapitel 14 trotz des gemeinsamen Namensbestandteils strikt zu unterschei-
den (siehe Abschnitt 15.9).
Die wichtigsten Klassen und Schnittstellen im HTTP/2 - API von Java sind:
• HttpRequest
Ein Objekt dieser Klasse repräsentiert eine Anfrage an einen HTTP-Server, ist nach der
Konfiguration unveränderlich und mehrfach verwendbar.
• HttpRequest.BodyPublisher
Diese statische Mitgliedsschnittstelle der Klasse HttpRequest definiert das Verhalten einer
Klasse, die bei Verwendung des POST-Protokolls einen reaktiven Strom mit den Parame-
tern veröffentlicht.
• HttpResponse<T>
Aus einer Anforderung resultiert ein die Schnittstelle HttpResponse<T> implementierendes
Objekt, das über seine Methoden statusCode(), headers() und body() den Statuscode, die
Header-Informationen und den Inhalt der Server-Antwort zugänglich macht.
• HttpResponse.BodyHandler<T>
Bei einer Anforderung wird neben einem HttpRequest-Objekt auch noch ein Objekt benö-
tigt, das die statische Mitgliedsschnittstelle BodyHandler<T> der Klasse HttpResponse er-
füllt. Seine Methode apply() liefert ein Objekt, das die Mitgliedsschnittstelle
BodySubscriber<T> der Klasse HttpResponse implementiert. Dieses Objekt kooperiert als
Subscriber mit dem HttpClient-Objekt, das die Publisher-Rolle übernimmt, über die Tech-
nik der reaktiven Ströme.
886 Kapitel 16 Netzwerkprogrammierung

• 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

16.2.4.1 Synchrone Anforderung einer statischen Webseite


Wir starten mit einem einfachen Programm, das den HTML-Code einer Webseite synchron anfor-
dert und in einem String-Objekt ablegt. Dazu ist die die synchron arbeitende Methode send() der
Klasse HttpClient zu verwenden:
import java.net.URI;
import java.net.http.*;

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();
}
}
}

Zunächst fällt positiv auf, dass ...


• von der im Hintergrund eingesetzten, nicht ganz trivialen Technik der reaktiven Ströme (vgl.
Abschnitt 15.9) im Programm nichts zu sehen ist,
• man über die bequeme Methode body() der Klasse HttpResponse<String> auf die Server-
Antwort zugreifen kann, ohne die traditionelle Datenstromtechnik im Sinn von Kapitel 14
verwenden zu müssen.

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

Zum Erstellen des HttpClient-Objekts kann im Beispiel die bequeme HttpClient-Fabrikmethode


newHttpClient() verwendet werden, weil keine Einsatzvorbereitung durch die Änderung von Vor-
einstellungen erforderlich ist:
HttpClient client = HttpClient.newHttpClient();
Um das Verhalten eines HttpClient-Objekts zu modifizieren, fordert man mit der statischen
HttpClient-Methode newBuilder() ein Objekt der Klasse HttpClient.Builder an. Diese Klasse
bietet ein Fluent-API mit diversen Konfigurationsmethoden. Im folgenden Beispiel wird über die
Methode followRedirects() für die Reaktion auf eine versuchte Weiterleitung zu einer anderen Ad-
resse (z. B. aufgrund eines Server-Umzugs) die Voreinstellung NEVER ersetzt durch die Alternati-
ve NORMAL (Zustimmung, wenn nicht durch die Weiterleitung die TLS/SSL - Verschlüsselung
abgeschaltet wird):
HttpClient client = HttpClient.newBuilder()
.followRedirects(HttpClient.Redirect.NORMAL)
.build();
Für den followRedirects() - Parameter sind die Werte der Enumeration HttpClient.Redirect zuge-
lassen. Mit der Methode build() wird schließlich die Erstellung des fertig konfigurierten
HttpClient-Objekts angefordert. Das in Englisch als builder pattern bezeichnete Programmiermus-
ter ist gleich auch bei der Klasse HttpRequest zu sehen.
Ein HttpClient-Objekt ist nach der Konfiguration unveränderlich und kann für mehrere Anforde-
rungen verwendet werden.
Beim HttpRequest-Objekt des Beispielprogramms kommt ein HttpRequest.Builder-Objekt zum
Einsatz, das den Uniform Resource Identifier (URI) zur Anforderung festlegt:1
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(uri))
.build();
Wird keine HTTP-Methode festgelegt, dann wird die Voreinstellung GET verwendet. Mit der
HttpRequest.Builder-Methode timeout() kann über einen Parameter vom Typ
java.time.Duration eine maximale Wartezeit für die Antwort zu einer Anforderung festlegt wer-
den.
Mit der synchronen (blockierenden) HttpClient-Methode send() wird die als erster Parameter er-
wartete Anforderung übermittelt:
HttpResponse<String> response = client.send(request,
HttpResponse.BodyHandlers.ofString());
Als zweiter Parameter wird ein HttpResponse.BodyHandler<T> benötigt. Im Beispiel wird ein
von der HttpResponse.Bodyhandlers-Fabrikmethode ofString() erstelltes Objekt übergeben, das
die Schnittstelle HttpResponse.BodyHandler<String> erfüllt. Die apply() - Methode des
BodyHandlers wird aufgerufen, sobald der HTTP-Server den Statuscode und die Response-Header
übermittelt hat, und erhält diese Informationen über den Parameter responseInfo:
public HttpResponse.BodySubscriber<String> apply(
HttpResponse.ResponseInfo responseInfo)
Die apply() - Rückgabe implementiert die statische Mitgliedsschnittstelle
BodySubscriber<String> der Klasse HttpResponse und arbeitet mit dem HttpClient-Objekt, das

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()

16.2.4.2 Asynchrone Anforderung einer statischen Webseite


Im nächsten Beispielprogramm wird der HTML-Code einer Webseite asynchron anfordert und in
einem String-Objekt ablegt. Dazu verwenden wir die HttpClient-Methode sendAsync(), die als
Parameter ...
• ein HttpRequest-Objekt
• sowie einem HttpResponse.BodyHandler<String>
erhält und als Rückgabe ein Objekt der Klasse CompletableFuture<HttpResponse<String>>
liefert:
import java.net.URI;
import java.net.http.*;
import java.util.concurrent.CompletableFuture;

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.

Ein join() - Methodenaufruf an das CompletableFuture<HttpResponse<String>> - Objekt liefert


ein Ergebnis vom Typ HttpResponse<String>, und bei diesem Objekt kann mit der Methode
body() ein String-Objekt mit dem bezogenen HTML-Code angefordert werden.
Das asynchrone Herunterladen in eine Datei gelingt auch ohne großen Aufwand:
CompletableFuture<HttpResponse<Path>> cfHRS =
client.sendAsync(request, HttpResponse.BodyHandlers.ofFile(file));
In diesem Fall enthält die asynchron ausgeführte Aufgabe ein HttpResponse<Path> - Objekt, das
auf Befragen mit body() über das beteiligte Path-Objekt informiert, z. B.:
Path p = cfHRS.join().body();
System.out.println("Ausgabe in die Datei " + p + ", Größe: " + Files.size(p));

16.2.4.3 Synchrone POST-Anforderung


In diesem Abschnitt wird erneut der Abruf einer dynamisch aufgrund von POST-Parametern er-
stellten Webseite behandelt. Während im Abschnitt 16.2.3.3 zu diesem Zweck die Klasse
HttpsURLConnection zum Einsatz kam, verwenden wir nun das HTTP/2 - API aus Java 11.
Im Unterschied zu den bisherigen Einsätzen der HttpRequest-Klasse sind zur Konfiguration zwei
zusätzliche Methoden erforderlich:
Abschnitt 16.2 Internet-Ressourcen per HTTP-Protokoll nutzen 891

String uri = "https://urtkurs.uni-trier.de/prokur/netz/cgip.php";


String name = "vorname=" + URLEncoder.encode(vorname, "UTF-8") +
"&nachname=" + URLEncoder.encode(nachname, "UTF-8");
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(uri))
.header("Content-Type", "application/x-www-form-urlencoded")
.POST(HttpRequest.BodyPublishers.ofString(name))
.build();
Mit der Methode header()wird für die POST-Daten in der Anforderung der Content-Type
application/x-www-form-urlencoded
vereinbart. Das ist eine plausible Einstellung, weil die POST-Daten im selben Format wie URL-
Parameter übergeben werden, wobei mit der URLEncoder - Methode encode() für die korrekte
Codierung der POST-Parameter gesorgt werden sollte (siehe Abschnitt 16.2.3.2). Außerdem hat
beim Zugriff auf eine mit PHP 7.2.24 erstellte CGI-Lösung dieser Content-Type zuverlässig ver-
hindert, das im PHP-Skript ein leerer Parameter-Array $_POST ankam.
Der Methode POST() wird ein HttpRequest.BodyPublisher übergeben, der einen reaktiven Strom
mit den POST-Daten veröffentlicht, den das HttpClient-Objekt als Subscriber konsumiert. Vom
Programmiermuster der reaktiven Ströme ist allerdings im Beispiel nur der Namensbestandteil Pub-
lisher zu sehen (vgl. Abschnitt 15.9). Wir lassen uns das erforderliche BodyPublisher-Objekt von
der Methode ofString() der Klasse HttpResponse.BodyPublishers erstellen. Wird das Programm
z. B. von Theresa Meyer benutzt, dann ist an ofString() die folgende Zeichenfolge zu übergeben:
vorname=Theresa&nachmane=Meyer
Bei einem derartig geringem POST-Datenumfang spielt die Technik der reaktiven Ströme keine
wichtige Rolle. Es kommt aber durchaus vor, dass die POST-Daten so umfangreich sind, dass sie
nicht komplett im Speicher gehalten werden sollen. In dieser Situation ist es nützlich, wenn der
HttpClient als Subscriber die vom BodyPublisher gelieferten Daten auf die Reise schickt und erst
danach mit der Subscription-Methode request() weitere Daten anfordert (vgl. Abschnitt 15.9).
Beim Versandt der Anforderung über die send() - Methode eines HttpClient-Objekts und beim
Zugriff auf die Server-Antwort bestehen keine Unterschiede im Vergleich zum Abschnitt 16.2.4.1
über die synchrone Anforderung einer statischen Webseite.
Neben der eben besprochenen HttpRequest-Konfiguration enthält das komplette Programm nur
vertraute Anweisungen:
import java.io.*;
import java.net.URI;
import java.net.URLEncoder;
import java.net.http.*;

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();

String uri = "https://urtkurs.uni-trier.de/prokur/netz/cgip.php";


String name = "vorname=" + URLEncoder.encode(vorname, "UTF-8") +
"&nachname=" + URLEncoder.encode(nachname, "UTF-8");
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(uri))
.header("Content-Type", "application/x-www-form-urlencoded")
.POST(HttpRequest.BodyPublishers.ofString(name))
.build();
892 Kapitel 16 Netzwerkprogrammierung

HttpClient client = HttpClient.newHttpClient();


HttpResponse<String> response = client.send(request,
HttpResponse.BodyHandlers.ofString());
System.out.printf("Statuscode: %d\n\n", response.statusCode());
java.util.StringTokenizer stok =
new java.util.StringTokenizer(response.body(), "\n", false);
while (stok.hasMoreTokens()) {
System.out.println(stok.nextToken());
}
} catch (Exception e) {
e.printStackTrace();
}
}
}

16.2.5 Nutzung der Browser-Komponente WebView in JavaFX-Anwendungen


Nachdem wir uns im Abschnitt 16.2 bisher auf Konsolenprogrammen beschränkt haben, setzen wir
nun die JavaFX-Komponente WebView ein, die einen ziemlich kompletten, auf der Open Source -
Engine WebKit basierenden Browser realisiert. Wer über die knappe Darstellung hinaus weitere
Informationen benötigt, findet diese z. B. in der OpenJFX-Dokumentation1 und in Sharan (2015, S.
711ff).
Ein Objekt der von javafx.scene.Parent abstammenden Klasse WebView konzentriert sich als Be-
standteil des Szenegraphen von JavaFX auf die Präsentation der Webseite und die Benutzerinterak-
tion. Für die Verarbeitung der Webseiteninhalte ist ein Objekt der Klasse WebEngine zuständig,
dem Objekte aus weiteren Klassen zuarbeiten (z.B. WebHistory und WebEvent). Alle genannten
Klassen befinden sich im Paket javafx.scene.web, das seit Java 9 zum Modul javafx.web gehört.
Der einzige Konstruktor der Klasse WebView besitzt keinen Parameter und erstellt automatisch ein
unterstützendes WebEngine-Objekt:
WebView webView = new WebView();
WebEngine engine = webView.getEngine();
Weil man oft mit dem WebEngine-Objekt arbeitet, sollte seine Adresse über die WebView-
Methode getEngine() erfragt und in einer Variablen abgelegt werden.
Um nach dem Laden einer Webseite deren Titel als Fenstertitel des Programms übernehmen zu
können, registriert man einen ChangeListener<String> bei der Title-Property des WebEngine-
Objekts:
engine.titleProperty().addListener(
(ObservableValue<? extends String> obs, String oldValue, String newValue) -> {
stage.setTitle(newValue);
});
Das meist per Lambda-Ausdruck realisierte Parameterobjekt muss die Methode changed() beherr-
schen:
public void changed(ObservableValue<? extends String> observable,
String oldValue, String newValue)
Mit der Methode load() beauftragt man das WebEngine-Objekt, die durch eine gültige URL-
Zeichenfolge identifizierte Webseite zu laden:
engine.load("https://www.google.de");

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);

Über die WebView-Methode setFontSmoothingType() mit einem Parameter vom Aufzählungstyp


FontSmoothingType
public final void setFontSmoothingType(FontSmoothingType type)
wählt man eine Methode zur Glättung von Schriften, z. B.:
webView.setFontSmoothingType(FontSmoothingType.GRAY);
Mit dem Enumerationskonstanten FontSmoothingType.GRAY als Parameterwert wird oft ein bes-
seres Ergebnis erzielt als mit der Voreinstellung FontSmoothingType.LCD, z.B.:
FontSmoothingType.LCD FontSmoothingType.GRAY

Zur Realisation des folgenden Browsers

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.*;

public class WebViewDemo extends Application {


@Override
public void start(Stage stage) {
WebView webView = new WebView();
WebEngine engine = webView.getEngine();
webView.setZoom(0.80);
webView.setFontSmoothingType(FontSmoothingType.GRAY);

engine.titleProperty().addListener(
(ObservableValue<? extends String> obs, String oldValue, String newValue) -> {
stage.setTitle(newValue);
});
engine.load("https://www.google.de");

double dist = 5.0;


Insets insets = new Insets(dist, dist, dist, dist);

TextField tfURL = new TextField();


Button btnLoad = new Button("Laden");
btnLoad.setDefaultButton(true);
btnLoad.setOnAction(event -> {
String s = tfURL.getText();
if (!s.startsWith("https://") && !s.startsWith("http://"))
s = "https://" + s;
engine.load(s);
});

HBox top = new HBox();


top.getChildren().addAll(tfURL, btnLoad);
HBox.setHgrow(tfURL, Priority.ALWAYS);
HBox.setMargin(btnLoad, new Insets(0, 0, 0, dist));

BorderPane root = new BorderPane();


root.setTop(top);
root.setCenter(webView);
BorderPane.setMargin(top, insets);
BorderPane.setMargin(webView, insets);

Scene scene = new Scene(root, 1024, 800);


stage.setScene(scene);
stage.show();
}
}

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.

16.3 IP-Adressen bzw. Host-Namen ermitteln


Jeder an das Internet angeschlossene Rechner verfügt über (mindestens) eine IP-Adresse (32-bittig
im IPv4, 128-bittig im IPv6) sowie über einen Hostnamen, wobei die Zuordnung vom Domain
Name System (DNS) geleistet wird.
In Java werden IP-Adressen durch Objekte der Klasse InetAddress repräsentiert. Zum Erzeugen
neuer InetAddress-Objekte fehlt ein öffentlicher Konstruktor, doch stehen für diesen Zweck stati-
sche InetAddress-Methoden bereit, z. B.:
• public static InetAddress getLocalHost()
Diese Methode liefert ein InetAddress-Objekt zum lokalen Rechner.
• public static InetAddress getByName(String host)
Diese Methode liefert ein InetAddress-Objekt zum Rechner mit dem angegebenen Host-
Namen.
• public static InetAddress getByAddress(byte[] address)
Diese Methode liefert ein InetAddress-Objekt zum Rechner mit der angegebenen IP-
Adresse.
Über die InetAddress-Methoden getHostAddress() bzw. getHostName() ermittelt man die IP-
Adresse bzw. den Host-Namen zu einem InetAddress-Objekt:
• public String getHostAddress()
• public String getHostName()
Das folgende Programm stellt für den lokalen Rechner die IP-Adresse und den Host-Namen fest:
import java.net.*;

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

• noch die Integrität der erhaltenen Pakete per Prüfsumme kontrollieren


• oder die erhaltenen Pakete in die korrekte Reihenfolge bringen.
Stattdessen können sich TCP-Programmierer darauf beschränken, nach den Regeln eines Protokolls
der Anwendungsschicht Bytes in einen Ausgabestrom zu schreiben bzw. Bytes aus einen Einga-
bestrom zu lesen und zu interpretieren.
Ein Socket-Objekt bietet einen Ausgabe- und einen Eingabestrom, und der Ausgabestrom des Sen-
ders ist virtuell mit dem Eingabestrom des Empfängers verbunden, sodass zwei Anwendungen mit
Hilfe ihrer Socket-Objekte bidirektional miteinander kommunizieren können:

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.

16.4.1.2 Ein Datetime-Klient


Mit der Methode getInputStream() besorgen wir uns beim Socket-Objekt seinen byte-orientierten
Eingabestrom, transformieren diesen per InputStreamReader in einen Zeichenstrom und greifen
über die bequeme readLine() - Methode eines BufferedReaders auf die Zeitansage des Daytime-
Servers zu:
import java.io.*;
import java.net.*;

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.1.3 Timeout setzen


Ein lesender Methodenaufruf an den InputStream eines Socket-Objekts blockiert per Voreinstel-
lung so lange, bis die erwarteten Daten eintreffen. Daher sollte in der Regel für ein Socket-Objekt
per setSoTimeout() eine maximale Wartezeit für das Lesen aus seinem InputStream festgelegt
werden, damit der ausführende Thread z. B. nicht zu lange durch einen überlasteten Server blockiert
wird:
time.setSoTimeout(1000);
Beim Überschreiten der vereinbarten Wartezeit (im Beispiel: 1000 Millisekunden) wird eine
SocketTimeoutException geworfen, auf die das Programm geeignet reagieren kann.
Die Methode setSoTimeout() ist relevant bei Lese- und Schreiboperationen, hat aber keinen Ein-
fluss auf die maximale Wartezeit bei der Verbindungsaufnahme (siehe Abschnitt 16.4.1.1).

16.4.1.4 Socket schließen


Mit einem close() - Aufruf an das Socket-Objekt, der im Beispielprogramm des Abschnitts 16.4.1.2
durch die seit Java 7 verfügbare try-with-resources - Anweisung erfolgt, erreicht man ...
• das Schließen der Verbindung und die Freigabe des lokalen Ports
• das Schließen der zugehörigen Datenströme
Obwohl das Socket-Objekt anschließend nicht mehr für Netzwerkzwecke taugt, liefert die Methode
isConnected() die Rückgabe true. Dieser Wert besagt lediglich, dass zuvor eine Verbindung vor-
handen war. Kommunikationsfähig ist ein Socket-Objekt nur dann, wenn ...
• isConnected() den Wert true liefert,
• und isClosed() den Wert false liefert.
Werden im Beispielprogramm die folgenden Anweisungen am Ende des try-Blocks eingefügt,
time.close();
System.out.println("isConnected(): " + time.isConnected());
System.out.println("netzwerkfähig: " + (time.isConnected() && !time.isClosed()));
dann erhält man die folgenden Informationen:
isConnected(): true
netzwerkfähig: false
900 Kapitel 16 Netzwerkprogrammierung

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.

16.4.2.1 Firewall-Ausnahme für einen TCP-Server unter Windows 10


Die Erreichbarkeit eines Serverprogramms an einem Port hängt vom Regelwerk der zuständigen
Firewall-Software ab. Unter Windows 10 erscheint beim Start eines per java.exe oder javaw.exe
ausgeführten Serverprogramms die folgenden Anfrage, wenn die Defender Firewall zuständig ist
und noch keine Firewall-Regeln zur beteiligten Java-Runtime kennt:

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;

try (ServerSocket timeServer = new ServerSocket(13);) {


System.out.println("Zeitserver gestartet (" + df.format(new Date()) + ")");
while(true)
try (Socket client = timeServer.accept()) {
PrintWriter pw = new PrintWriter(client.getOutputStream(), true);
zeit = df.format(new Date());
System.out.println("\n" + zeit + " Anfrage von\n IP-Nummer: " +
client.getInetAddress().getHostAddress() +
" (Port: " + client.getPort() + ")");
pw.println(zeit);
}
} catch (Exception e) {
System.err.println(e);
}
}
}
Während unser Datetime-Klient (siehe Abschnitt 16.4.1) nach einer Anfrage beendet ist, lauscht der
Server permanent an Port 13 und bedient nacheinander beliebig viele Klienten, z.B.:
Zeitserver gestartet (03.05.2022 03:51:54)

03.05.2022 03:57:25 Anfrage von


IP-Nummer: 192.168.178.91 (Port: 47806)

03.05.2022 04:00:32 Anfrage von


IP-Nummer: 192.168.178.26 (Port: 55453)

03.05.2022 04:41:01 Anfrage von


IP-Nummer: 0:0:0:0:0:0:0:1 (Port: 63285)
902 Kapitel 16 Netzwerkprogrammierung

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();

public static void main(String[] args) {


ExecutorService es = Executors.newCachedThreadPool();
try (ServerSocket echoServer = new ServerSocket(7)) {
System.out.println("Echoserver gestartet (" + df.format(new Date()) + ")");
while(true) {
try {
Socket client = echoServer.accept();
nconn++;
nummer++;
prot("Verbindung Nummer " + nummer + ":\n IP-Nummer: "
+ client.getInetAddress().getHostAddress()
+ " (Port: " + client.getPort() + ")");
es.execute(new EchoTask(client, nummer));
} catch (Exception e) {
prot("Fehler bei der Klientenbedienung: \n " + e);
}
}
} catch (Exception e) {
prot("Server konnte nicht gestartet werden: \n " + e);
}
}

static synchronized void decr(int nummer) {


nconn--;
System.out.println("\n" + df.format(new Date()) + " Verbindung Nummer " +
nummer + " beendet");
}

static synchronized int getNConnects() {return nconn;}

static void prot(String s) {


System.err.println("\n"+df.format(new Date())+" "+s);
}
}

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.*;

class EchoTask implements Runnable {


private Socket client;
private int nummer;

EchoTask(Socket cl, int nr) {


client = cl;
nummer = nr;
}
public void run() {
String zeile;
try (BufferedReader br = new BufferedReader(
new InputStreamReader(client.getInputStream()));
PrintWriter pw = new PrintWriter(client.getOutputStream(), true)) {
pw.println("Echo-Server (Verbindung: " + nummer + ", aktiv: " +
EchoServer.getNConnects() + ")");
while (client.isConnected() && (zeile = br.readLine()) != null) {
if (zeile.trim().equalsIgnoreCase("quit")) {
pw.println("Gut, dass wir darueber geredet haben!");
break;
}
pw.println(zeile);
}
} catch (Exception e) {
EchoServer.prot("Fehler: \n " + e + " (Verbindung " + nummer + ")");
} finally {
EchoServer.decr(nummer);
}
}
}

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

16.5 Übungsaufgaben zu Kapitel 16


1) Welche von den folgenden Aussagen sind richtig bzw. falsch?
1. Einem TCP-Server wird seine Port-Nummer beim Start zugewiesen.
2. Über ein Objekt der Klasse InetAddress lässt sich der Host-Name zu einer IP-Adresse er-
mitteln.
3. Im OSI-Modell ist das HTTP-Protokoll auf der Ebene 5 einzuordnen.
4. Die Socket-Methode setSoTimeout() hat keinen Einfluss auf die maximale Wartezeit bei
der Verbindungsaufnahme.

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

++, -- Prä- oder Postinkrement bzw. -dekrement

- Vorzeichenumkehr

(Typ) Typumwandlung

new Objekterzeugung

*, / Punktrechnung

% Modulo

+, - Strichrechnung

+ String-Verkettung

<<, >> Links- bzw. Rechts-Shift

>, <,
Vergleichsoperatoren
>=, <=

instanceof Typprüfung

==, != Gleichheit, Ungleichheit

& Bitweises UND

& Logisches UND (mit unbedingter Auswertung)

^ Exklusives logisches ODER

| Bitweises ODER

| Logisches ODER (mit unbedingter Auswertung)


908 Anhang

Operator Bedeutung

&& Logisches UND (mit bedingter Auswertung)

|| Logisches ODER (mit bedingter Auswertung)

?: 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.

B. Lösungsvorschläge zu den Übungsaufgaben

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

Kapitel 2 (Werkzeuge zum Entwickeln von Java-Programmen)


Aufgabe 3
Das Programm enthält folgende Fehler:
• Die schließende Klammer zum Rumpf der Klassendefinition fehlt.
• Die Zeichenfolge im println() - Aufruf muss mit dem " - Zeichen abgeschlossen werden.
• Der Methodenname „mein“ ist falsch geschrieben.
• Die Methode main() muss als public definiert werden.
Abschnitt B Lösungsvorschläge zu den Übungsaufgaben 909

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.

Kapitel 3 (Elementare Sprachelemente)

Abschnitt 3.1 (Einstieg)


Aufgabe 1
Dieser Aufruf public static void main(String[] irrelevant) { … }
klappt: Der Name des main() - Parameters ist beliebig.
Dieser Aufruf public void main(String[] args) { … }
scheitert: Der Modifikator static fehlt.
Dieser Aufruf public static void main() { … }
scheitert: Falsche Parameterliste
Dieser Aufruf static public void main(String[] args) { … }
klappt: Die Modifikatoren static und public müssen vor dem Rückgabetyp stehen,
wobei ihre Reihenfolge irrelevant ist (siehe Syntaxdiagramm im Abschnitt
3.1.3.2).
Dieser Aufruf public static void main(String[] Args) { … }
klappt: Der Parametername ist frei wählbar, und Args verstößt gegen keine Com-
piler-Regel. Per Konvention verwendet man allerdings bei Parameterna-
men wie bei Variablennamen das Camel Casing (siehe Abschnitt 3.3.2),
startet also mit einem Kleinbuchstaben
Dieser Aufruf static public void Main(String[] args) { … }
scheitert: Der Anfangsbuchstabe im Methodennamen main() muss klein geschrieben
werden.
Dieser Aufruf static public int main(String[] args) { … }
scheitert: 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 Exit-Codes 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 Exit-Code an das
Betriebssystem übergeben werden (siehe Abschnitt 11.2).

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).

Abschnitt 3.2 (Ausgabe bei Konsolenanwendungen)


Aufgabe 1
Das folgende Programm erzeugt die erwünschte Ausgabe:
class Prog {
public static void main(String[] args) {
System.out.printf("%1$-10.1f %1$-10.2f %1$-10.3f%n", Math.PI);
System.out.printf("%1$-10.4f %1$-10.5f %1$-10.6f", Math.PI);
}
}
Wenn die längliche Formatierungszeichenfolge nicht stört, kommt man auch mit einem printf() -
Aufruf aus:
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);
}
}

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.

Abschnitt 3.3 (Variablen und Datentypen)


Aufgabe 1
1. Falsch
2. Richtig
3. Falsch
Referenzvariablen haben einen bestimmten Inhalt (eine Objektadresse). Sie werden als loka-
le Variablen von Methoden (abgelegt auf dem Stack), als Instanzvariablen von Objekten
(abgelegt auf dem Heap) und als Klassenvariablen (abgelegt in der Method Area) benötigt.
4. Falsch
Dieser Satz ist kompletter Unfug.

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);
}
}

Abschnitt 3.4 (Eingabe bei Konsolen)


Aufgabe 1
Im folgenden Programm werden die Simput-Methoden gchar() und gdouble() verwendet:
class Prog {
public static void main(String[] args) {
System.out.print("Setzen Sie bitte ein Zeichen: ");
char c = Simput.gchar();
System.out.println("Ihr Zeichen: " + c);
System.out.print("\nNun bitte eine gebrochene Zahl (mit Dezimalkomma!): ");
double d = Simput.gdouble();
System.out.printf("Ihre Zahl: " + d);
}
}
Abschnitt B Lösungsvorschläge zu den Übungsaufgaben 913

Abschnitt 3.5 (Operatoren und Ausdrücke)


Aufgabe 1
Ausdruck Typ Wert Anmerkungen
6/4*2.0 double 2.0 Abarbeitung mit Zwischenergebnissen:
6/4 = 1
1*2.0 = 2.0
(int)6/4.0*3 double 4.5 Der Typumwandlungsoperator hat die höchste Bindungs-
kraft und bezieht sich daher (ohne Wirkung) auf die 6.
Abarbeitung mit Zwischenergebnissen:
(int)6 = 6 //überflüssige Typumwandlung
6/4.0 = 1.5
1.5*3 = 4.5
(int)(6/4.0*3) int 4 Abarbeitung mit Zwischenergebnissen:
6/4.0 = 1.5
1.5*3 = 4.5
(int)4.5 = 4
3*5+8/3%4*5 int 25 Abarbeitung mit Zwischenergebnissen:
3*5 = 15
8/3 = 2
2%4 = 2
2*5 = 10
15+10 = 25

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

Abschnitt 3.6 (Über- und Unterlauf bei numerischen Variablen)


Aufgabe 1
1. Falsch
2. Richtig
3. Falsch
4. Falsch
Bei Objekten aus den Klassen BigDecimal und BigInteger tritt im Vergleich zu den primi-
tiven Datentypen ein erheblich höherer Speicher- und Zeitaufwand auf. Sie sollten daher nur
verwendet werden, wenn der größere Wertebereich und/oder die höhere Genauigkeit tat-
sächlich erforderlich sind.

Abschnitt 3.7 (Anweisungen (zur Ablaufsteuerung))


Aufgabe 1
Im logischen Ausdruck der if-Anweisung findet an Stelle eines Vergleichs eine Zuweisung statt.

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

Vorz. Exponent Mantisse


0 00000000000 1000000000000000000000000000000000000000000000000000
und bei 2-1074 = 2-1022  2-52 ( 4,9406564584124654  10-324)
Vorz. Exponent Mantisse
0 00000000000 0000000000000000000000000000000000000000000000000001
ist letztmals eine Unterscheidung von der Zahl 0,0 möglich.

Kapitel 4 (Klassen und Objekte)


Aufgabe 1
1. Richtig
2. Falsch
Mit der Datenkapselung wird verhindert, dass die Methoden fremder Klassen auf Instanzva-
riablen zugreifen. Es geht nicht darum, Objekte einer Klasse voreinander zu schützen. Die
von einem Objekt ausgeführten Methoden haben stets vollen Zugriff auf die Instanzvariab-
len eines anderen Objekts derselben Klasse, sofern eine entsprechende Referenz vorhanden
ist. Der Klassendesigner ist für das sinnvolle Verhalten der Methoden verantwortlich.
3. Falsch
Ohne Schutzstufendeklaration haben alle Klassen im selben Paket vollen Zugriff.
4. Falsch
Lokale Variablen werden grundsätzlich nicht initialisiert, auch die lokalen Referenzvariab-
len nicht.

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

Kapitel 5 (Wichtige spezielle Klassen)

Abschnitt 5.1 (Arrays)


Aufgabe 1
1. Falsch
2. Richtig
3. Richtig
4. Richtig
5. Falsch
Für diesen Zweck ist die finalisierte Instanzvariable length zu verwenden.

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

Abschnitt 5.2 (Klassen für Zeichen)


Aufgabe 1
1. Falsch
Weder ist ein String-Objekt ein Array, noch erfüllt die Klasse String das Interface Iterable
oder das Interface Iterable<T> (vgl. Abschnitt 3.7.3.2).
2. Richtig
3. Falsch
Stattdessen ist die String-Methode charAt() zu verwenden.
4. Richtig
Für variable Zeichenketten eignen sich die Klassen StringBuilder und StringBuffer.

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

Abschnitt 5.3 (Verpackungsklassen für primitive Datentypen)


Aufgabe 1
Lösungsvorschlag:
Quellcode Ausgabe
class Prog { Min. byte-Zahl:
public static void main(String[] args) { -128
System.out.println("Min. byte-Zahl:\n " + Byte.MIN_VALUE);
}
}

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

Abschnitt 5.4 (Aufzählungstypen)


Aufgabe 1
Sie finden einen Lösungsvorschlag im Verzeichnis:
...\BspUeb\Wichtige spezielle Klassen\Enumerationen\Wochentage

Abschnitt 5.5 (Records)


Aufgabe 1
1. Richtig
2. Richtig
3. Falsch
4. Richtig

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).

Kapitel 6 (Pakete und Module)


Aufgabe 1
Beide Klassen gehören zum Standardpaket und haben die voreingestellte Sichtbarkeit package, so-
dass sie sich gegenseitig sehen können. Weil die Worker-Methode ohne Zugriffsmodifikator defi-
niert wurde, besitzt sie die voreingestellte Schutzstufe package und kann von allen Klassen im sel-
ben Paket verwendet werden. Auch der Standardkonstruktor der Klasse Worker ist für alle Klassen
im selben Paket verfügbar, weil er dieselbe Schutzstufe besitzt wie seine Klasse (nämlich package).
Abschnitt B Lösungsvorschläge zu den Übungsaufgaben 921

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.

Kapitel 7 (Vererbung und Polymorphie)


Aufgabe 1
In der Klasse Spezial kommt der Standardkonstruktor zum Einsatz, welcher den parameterfreien
Basisklassenkonstruktor benötigt. Ein solcher fehlt aber in der Klasse General.

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

Kapitel 8 (Generische Klassen und Methoden)


Aufgabe 1
Lösungsvorschlag:
static <T> void printAll(List<T> list) {
for (T e : list)
System.out.println(e);
}
Wie das Beispiel demonstriert, können manche Methoden alternativ mit Typformalparametern oder
mit Wildcard-Datentypen realisiert werden (siehe Bloch 2018, S. 144f). Die Typformalparametern-
Technik hat den Vorteil, dass der konkretisierende Datentyp in der Methodendefinition mehrfach
genutzt werden kann. In der for-Schleife des Beispielprogramms kann allerdings statt des Typpa-
rameters T auch die Klasse Object verwendet werden:
static <T> void printAll(List<T> list) {
for (Object e : list)
System.out.println(e);
}

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

Kapitel 10 (Java Collections Framework)


Aufgabe 1
Sie finden einen Lösungsvorschlag im Verzeichnis:
...\BspUeb\Java Collections Framework\Mengen\BaumLotto

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

try Anweisungsblock catch-Block finally Anweisungsblock

catch-Block

catch ( Ausnahmeklassenliste Parametername ) Anweisungsblock

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

Kapitel 12 (Funktionales Programmieren)


Aufgabe 1
Sie finden einen Lösungsvorschlag im Verzeichnis:
...\BspUeb\Funktionales Programmieren\Lambda-Ausdrücke\LambdaVsAK
Mit einem Lambda-Ausdruck ist das Problem sehr elegant zu lösen:
int sq100 = IntStream.rangeClosed(1, 100)
.map(n -> n*n)
.sum();
Bei Verwendung einer anonymen Klasse steigt der Aufwand, während sich die Lesbarkeit des
Quellcodes verschlechtert:
int sq100 = IntStream.rangeClosed(1, 100)
.map(new IntUnaryOperator() {
public int applyAsInt(int n) {
return n*n;
}
})
.sum();

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

Kapitel 13 (GUI-Programmierung mit JavaFX)


Aufgabe 1
1. Falsch
Meist ist ein Layoutmanager als Wurzelknoten tätig. Zur Realisation komplexer Bedien-
oberflächen werden Layoutmanager oft auch als eingeschachtelte Kindelemente auf ver-
schiedenen Hierarchieebenen des Szenegraphen eingesetzt.
2. Richtig
3. Falsch
In der Application-Ableitung zu einer JavaFX-Anwendung wird keine Methode namens
main() benötigt. Ist eine solche Methode vorhanden, dann sollte sie sich auf einen Aufruf
der Methode launch() beschränken:
launch(args);
Auch eine JavaFX-Anwendung enthält hinter den Kulissen eine Startmethode namens
main() im Sinn von Abschnitt 1.1.4, die aber normalerweise vom Anwendungsprogrammie-
rer nicht direkt genutzt oder gar modifiziert wird.
4. Falsch
Es ist zumindest bei komplexen Bedienoberflächen sinnvoll, eine Deklaration per FXML
vorzunehmen, doch kann auch eine JavaFX-Anwendung komplett durch Anweisungen reali-
siert werden.
5. Richtig

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

Kapitel 14 (Ein- und Ausgabe über Datenströme)


Aufgabe 1
1. Falsch
Es stimmt, dass die schon in Java 1.0 vorhandene Klasse PrintStream durch die Klasse
PrintWriter ersetzt worden ist. Der per System.in ansprechbare Standardausgabestrom so-
wie der per System.err ansprechbare Standardfehlerausgabestrom werden allerdings nach
wie vor durch Objekte der Klasse PrintStream realisiert.
2. Richtig
3. Richtig
4. Richtig
Abschnitt B Lösungsvorschläge zu den Übungsaufgaben 927

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

class Schnarcher extends Thread {


@Override
public void run() {
while (true) {
if(isInterrupted())
return;
try {
sleep(100);
} catch(InterruptedException ie) {interrupt();}
System.out.print("*");
}
}
}
Im aktuellen Beispiel wäre auch eine return-Anweisung im catch-Block angemessen, um die run()
- Methode und den Thread zu beenden.

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

Kreft, K & Langer, A. (2015). Java 8. CompletableFuture. Online-Dokument:


http://www.angelikalanger.com/Articles/EffectiveJava/79.Java8.CompletableFuture/79.Java8.
CompletableFuture.html
Krüger, G. & Hansen, H. (2014). Java-Programmierung - Das Handbuch zu Java 8. Köln: O’Reilly
Langbridge, J.A. (2014). Professional Embedded ARM Development. Indianapolis: Wiley & Sons.
Laskey, J. & Marks, S. (2020). Programmer's Guide to Text Blocks. Online-Dokument:
https://docs.oracle.com/en/java/javase/17/text-blocks/index.html
Lau, O. (2009). Faites vos jeux! Zufallszahlen erzeugen, erkennen und anwenden. c’t Magazin für
Computertechnik. 2009, Heft 2, 172-178.
Lahres, B. & Rayman, G. (2009). Praxisbuch Objektorientierung (2. Aufl.). Professionelle Ent-
wurfsverfahren. Bonn: Galileo
Langer, A. & Kreft, K. (2004). Java Generics - Type Erasure. Online-Dokument:
http://www.angelikalanger.com/Articles/JavaMagazin/Generics/GenericsPart2.html
Liskov, B. H. & Wing, J. M. (1999). Behavioral Subtyping Using Invariants and Constraints. Onli-
ne-Dokument: https://apps.dtic.mil/sti/pdfs/ADA367674.pdf
Mahn, J. (2021). Strukturkunde. Datenstrukturen serialisieren mit JSON, YAML, XML & Co. c’t
Magazin für Computertechnik. 2021, Heft 19, 128-131.
Mak, S. & Bakker, P. (2017). Java 9 Modularity. Sebastopol, CA: O’Reilly.
Martin, R.C. (2002). SRP: The Single Responsibility Principle. Online-Dokument:
https://web.archive.org/web/20140407020253/http://www.objectmentor.com/resources/article
s/srp.pdf
Mössenböck, H. (2003). Softwareentwicklung mit C#. Ein kompakter Lehrgang. Heidelberg:
dpunkt.
Mössenböck, H. (2005). Sprechen Sie Java? Einführung in das systematische Programmieren. Hei-
delberg (3. Aufl.). Heidelberg, dpunkt.Verlag.
Naftalin, M. & Wadler, P. (2007). Java Generics and Collections. Sebastopol, CA: O’Reilly.
Oaks, S. (2014). Java Performance: The Definitive Guide. Sebastopol, CA: O’Reilly.
Oechsle, R. (2018). Parallele und verteilte Anwendungen in Java. München: Hanser.
Oracle (2010). Java Object Serialization Specification. Online-Dokument:
https://docs.oracle.com/javase/8/docs/platform/serialization/spec/serialTOC.html.
Oracle (2021a). The Java Tutorials. Online-Dokument: http://download.oracle.com/javase/tutorial/.
Oracle (2021b). Java Platform, Standard Edition, Oracle JDK Migration Guide. Online-Dokument:
https://docs.oracle.com/en/java/javase/17/migrate/index.html
Petre, M. (2013). UML in practice. In: 35th International Conference on Software Engineering
(ICSE 2013), 18-26 May 2013, San Francisco, CA, USA (forthcoming), pp. 722–731.
Reinhold, M. (2016). The State of the Module System. Online-Dokument:
http://openjdk.java.net/projects/jigsaw/spec/sotms/
Sharan, K. (2015). Learn JavaFX 8. New York: Apress.
Simons, R. (2004). Hardcore Java. Sebastopol, CA: O’Reilly.
Strey, A. (2005). Computer-Arithmetik. Online-Dokument:
http://www.informatik.uni-ulm.de/ni/Lehre/SS05/CompArith/
Ullenboom, C. (2016). Java ist auch eine Insel (12. Aufl.). Bonn: Rheinwerk. OpenBook:
http://openbook.rheinwerk-verlag.de/javainsel/
932 Literatur

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

Ausdrücke ..................................................126 BlockingQueue<E> .................................. 785


Ausdrucksanweisungen ..............................156 BMP ........................................................... 675
Ausführungskonfiguration ...................80, 167 BodyHandler<T> ....................................... 887
Ausnahmen.................................................529 BodySubscriber<T>................................... 887
Auswertungsreihenfolge ............................145 Boilerplate-Code ................................ 320, 576
Autoboxing.................................................308 boolean ....................................................... 103
AutoCloseable ....................................565, 695 boolean-Literale ......................................... 117
autoFlush BorderPane ................................................ 670
PrintStream.............................................719 Boxing........................................................ 308
PrintWriter .............................................731 break-Anweisung ............................... 164, 182
Automatische Typanpassung .....................139 breakpoint .................................................. 226
Automatisches Modul ................................384 Brückenklassen .......................................... 725
Autounboxing.............................................309 Bucket ................................................ 501, 517
available()...................................................714 BufferedInputStream ......................... 721, 760
FileInputStream ......................................721 BufferedOutputStream ....................... 716, 759
average().............................................593, 604 BufferedReader .......................................... 736
await() ................................................783, 801 BufferedWriter ........................................... 729
AWT...........................................................615 builder pattern ............................................ 887
B Button ........................................................ 676
back pressure ......................................851, 853 byte ............................................................ 102
Balancierter Binärbaum .............................503 ByteArrayInputStream ....................... 721, 742
BasicFileAttributes.....................................701 ByteArrayOutputStream .................... 712, 742
Beans ..........................................................642 Bytecode ...................................................... 24
Bedingte Anweisung ..................................157 C
Bedingte Thread-Sicherheit .......................843 C++ ...................................................... 31, 112
Befehlsschalter ...........................................676 Calendar ..................................................... 702
Benannte Anweisungen..............................184 Call Back - Routinen ................................. 614
Benutzer-Thread.........................................770 Callable<T> ............................................... 810
Beobachtbare Listen ...................................654 Callback<P, R> .......................................... 657
Beschränkte Typformalparameter ..............432 Camel Casing ............................... 98, 209, 217
Bezeichner ....................................................89 cancel() ...................................................... 850
Big Ball of Mud .........................................358 canWrite() .................................................. 708
BigDecimal ................106, 133, 154, 155, 744 CAS............................................................ 799
Big-Endian .................................................726 case-Marke ................................................. 164
BigInteger...................................................151 Casting ....................................................... 139
Binärbaum ..................................................503 Casting-Operator........................................ 140
Binäre Gleitkommadarstellung ..................104 catch-Block ........................................ 533, 534
Binäre Operatoren ......................................127 ceiling()
BinaryOperator<T> ...................................601 NavigableSet<E> ................................... 509
binarySearch() ceilingEntry()
Arrays .....................................................288 NavigableMap<K,V> ............................ 520
Collections .............................................524 ceilingKey()
bind() ..................................................649, 652 NavigableMap<K,V> ............................ 519
bindBidirectional() .....................................649 CGI ............................................................ 880
Binding-Klassen .........................................650 changed()
Bindings .....................................................652 ChangeListener<T> .............................. 647
Bindungskraft .............................................145 ChangeListener<? super T>....................... 647
Bitorientierte Operatoren ...........................138 Channel .............................. 689, 714, 715, 723
Bitweises UND ..........................................139 char ............................................................ 103
Block ..........................................................111 Character .................................................... 313
Blockanweisung .................................111, 156 CharArrayReader ....................................... 735
Blockierender Methodenaufruf ..................804 CharArrayWriter ........................................ 725
Index 935

charAt() ......................................................299 ConcurrentSkipListSet<E> ........................ 844


char-Literale ...............................................117 Condition ................................................... 783
CharSequence.............................................467 connect() ............................................ 877, 898
Charset ...............................................726, 753 Constable ................................................... 448
CheckBox ...................................................680 ConstantDesc ............................................. 448
checked exceptions ....................................551 contains()
checkError() ...............................545, 718, 731 Collection<E> ........................................ 483
children.......................................................637 Map<K, V> ............................................ 512
Class ...................................................199, 472 containsAll()
ClassCastException ....................418, 422, 426 Collection<E> ........................................ 483
classpath containsValue()
-Kommandozeilenargument .....................44 Map<K, V> ............................................ 512
CLASSPATH .......................................18, 342 continue-Anweisung .................................. 182
-Umgebungsvariable ................................43 Control ....................................................... 625
Cleaner .......................................................244 Controller ........................................... 272, 637
clear() controls ...................................................... 613
Collection<E> ........................................483 copy()
Map<K,E> .............................................512 Files........................................................ 703
Clojure........................................................592 copyOf() ............................................. 288, 430
close() .........................................242, 694, 717 CopyOnWriteArrayList<E> ...................... 845
closure ........................................................581 CopyOnWriteArraySet<E> ....................... 845
Codierung ...................................................739 CopyOption................................................ 704
collect() ......................................593, 594, 604 Corretto ........................................................ 37
Collection<E> ....................................483, 486 cos() ........................................................... 281
Collections .................493, 500, 511, 523, 842 count() ........................................ 592, 594, 604
Collectors ...................................................605 countDown() .............................................. 792
ColumnConstraints.....................................667 CountdownLatch........................................ 792
columnIndex...............................................666 Cp1252 ....................................................... 726
columnSpan ................................................667 Cp850 ......................................................... 726
Common Gateway Interface ......................880 CPU.............................................................. 23
Common Pool ............................815, 820, 856 createDirectories ........................................ 700
commonPool()............................................815 createDirectory() ........................................ 700
Comparable<T> .................................448, 450 createFile() ................................................. 700
comparator() createNewFile()
SortedMap<K,V>...................................518 File ......................................................... 708
SortedSet<E> .........................................507 CSS .................................................... 616, 686
Comparator<E>..................................506, 518 currentThread() .......................................... 771
Comparator<T>..........................................627 currentTimeMillis() ........................... 193, 289
Compare-and-Set .......................................799 Custom Modular Runtime Image .............. 382
compareAndSet() .......................................799 CyclicBarrier.............................................. 793
compareTo() .......................................432, 448 D
Path.........................................................699 Daemon-Thread ................................. 815, 860
String ......................................................298 Dalvik .......................................................... 28
Compiler.......................................................24 dangling else .............................................. 161
CompletableFuture<T> ......................819, 885 DataInputStream ................ 563, 692, 723, 760
complete() ..................................................819 DataOutputStream ..................... 693, 715, 759
completeExceptionally() ............................819 Datei
CompletionException.................................842 explizit erstellen ab Java 7 ..................... 700
compute() ...................................................816 explizit erstellen in Java 6...................... 708
computeValue() ..........................................652 löschen ab Java 7 ................................... 705
ConcurrentHashMap<K,V> .......................843 löschen in Java 6 .................................... 710
ConcurrentSkipListMap<K,V> .................844 umbenennen in Java 6 ............................ 710
936 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

File .........................................................707 finalize() ............................. 242, 564, 694, 717


exit() finally ................................................. 537, 563
Platform ..................................................623 finally-Block .............................................. 533
System ....................................................531 findAny() ................................................... 608
Exitcode .....................................................531 findFirst() ................................................... 608
Exklusives logisches ODER ......................137 Firewall ...................................................... 900
Explizite Lock-Objekte ..............................780 first()
Expliziten Module ......................................384 SortedSet<E> ......................................... 507
Explodiertes Modul ....................................367 firstEntry()
exports (JPMS) ...........................................362 NavigableMap<K,V> ............................ 519
extends firstKey()
Typrestriktion .........................................433 SortedMap<K,V> .................................. 518
Vererbung...............................................395 Fisher-Yates - Algorithmus ....................... 918
F fixed arity methods .................................... 221
Fabrikmethode ...........................................239 Fließkommazahl ........................................ 103
fail-fast ...............................................497, 498 float .................................................... 102, 133
Fakultät...............................................329, 602 floating point number ................................ 103
Faltung .......................................................601 floor()
Fehlerausgabestrom ...................................700 NavigableSet<E> ................................... 509
Fehlerstatus ................................................544 floorEntry()
Fehlerstatuskontrolle ..................................122 NavigableMap<K,V> ............................ 520
Felder floorKey()
überdecken .............................................403 NavigableMap<K,V> ............................ 519
FIFO ...........................................................785 Flow-API ................................................... 851
File .....................................................696, 707 Fluent API.................................. 586, 651, 822
FileAlreadyExistsException...............700, 704 flush() ................................................. 717, 763
FileDescriptor.....................................720, 762 Flussdiagramm ........................................... 157
FileInputStream ..................564, 714, 722, 760 followRedirects() ....................................... 887
FilenameFilter ............................................710 Font ............................................................ 681
FileNotFoundException .............................712 font() .......................................................... 681
FileOutputStream ...............................712, 759 FontSmoothingType .................................. 893
FileReader ..................................................736 forEach() .................................................... 600
Files ............................714, 723, 733, 737, 752 fork() .......................................................... 817
FileStore .....................................................705 Fork-Join - Framework .............................. 814
FileSystem ..................................................705 ForkJoinPool .............................................. 815
FileSystems ................................................705 ForkJoinTask<T> ...................................... 815
FileTime .....................................................701 Formalparameter ........................................ 218
FileWriter ...................................................728 format() ................................................ 92, 278
fill() PrintWriter ............................................. 731
Arrays .....................................................288 Formatierungszeichenfolge.......................... 92
filter() .................................................592, 594 Formatter...................................................... 94
Filterklassen ...............................................692 Formular
Filter-Map-Reduce .....................................609 HTML .................................................... 880
Filter-Map-Reduce - Muster ......................587 for-Schleife ................................................ 177
FilterReader ................................................735 FowPane .................................................... 672
FilterWriter.................................................725 Framework ......................................... 449, 481
final ....................................113, 212, 402, 412 Freigabe von Ressourcen ........................... 563
Finalisierte fromMillis() ............................................... 702
Instanzvariablen .....................................212 Function<T,R>.......................................... 597
Klassen ...................................................412 FunctionalInterface .................................... 571
Methoden ...............................................402 Funktion höherer Ordnung ........................ 611
Finalisierte lokale Variablen ......................113 Funktionale Programmierung .................... 569
938 Index

Funktionale Schnittstellen ..........................570 getLocalHost() ........................................... 895


Funktionsobjekt ..........................................572 getLocalPort() ............................................ 899
Future<T> ..................................................810 getMessage() ...................................... 542, 555
FXCollections ............................626, 633, 654 getName()
FXML.........................................................635 File ......................................................... 710
FXMLLoader .............................................622 Path ........................................................ 698
G getNameCount() ........................................ 698
Ganzzahlarithmetik ....................................128 getOutputStream() ..................................... 884
Ganzzahlliterale .........................................114 getParent().......................................... 697, 705
Garbage Collector ........................32, 240, 860 getPath()
gc() .............................................................243 URL ....................................................... 876
gchar() ................................................138, 181 getPort() ..................................................... 902
gdouble() ....................................................191 getPriority()................................................ 804
Gegendruck ........................................851, 853 getProperty() .............................................. 697
generate() ...................................................591 getResponseCode() .................................... 878
Generische getResponseMessage() .............................. 878
Methoden ...............................................435 getRoot() .................................................... 698
Generizität ..................................................417 getState() ............................................ 786, 806
Geprüfte Ausnahmen .................................551 getter .......................................................... 214
GET getText() .................................................... 679
CGI-Parameter .......................................882 getTotalSpace() .......................................... 706
get() getUncaughtExceptionHandler() ....... 532, 542
Callable<T> ...........................................811 getUsableSpace() ............................... 706, 708
List<E> ..................................................488 GGT ............................................................... 9
Map<K,V> .............................................512 GIF ............................................................. 675
Paths .......................................................697 gint() .................................................. 121, 741
getAbsolutePath().......................................708 GitHub ......................................................... 63
getAndAdd() ..............................................798 Gitterlinien ......................................... 627, 666
getAndDecrement() ....................................798 Gleitkommaarithmetik ....................... 106, 128
getAndIncrement() .....................................798 Gleitkommadarstellung
getAnnotation() ..........................................472 binär ....................................................... 104
getByAddress() ..........................................895 dezimal ................................................... 106
getByName() ..............................................895 Gleitkommaliterale .................................... 116
getCause() ..................................................555 Gleitkommazahl ......................................... 103
getClass() ...................................................199 Globale Variablen ...................................... 101
getDefault() ................................................706 Gluon ................................................... 71, 616
getEngine() .................................................892 GMT .......................................................... 703
getExpiration() ...........................................878 GridPane .................................... 626, 636, 665
getFileName() ............................................697 Größter gemeinsamer Teiler .......................... 9
getFileStores() ............................................706 groupingBy().............................................. 606
getFirst() GUI ............................................................ 613
Deque<E> ..............................................522 Gültigkeitsbereich ...................................... 206
getHostAddress() ...............................895, 902 lokale Variablen ..................................... 111
getHostName() ...........................................895 H
getInetAdress() ...........................................902 halignment ................................................. 667
getInputStream() Hallo-Beispielprogramm ............................. 37
Socket .....................................................898 handle() ...................................................... 837
URLConnection .....................................878 hash() ......................................................... 320
getLast() Hashcode.................................................... 400
Deque<E> ..............................................522 hashCode() ......................................... 487, 502
getLastModified() ......................................878 Hash-Funktion ........................................... 502
getLastModifiedTime() ..............................701 Hash-Kollision ........................................... 502
Index 939

HashMap<K,V> .................................431, 516 IllegalStateException ................................. 852


Hashtabelle .................................................501 Image ......................................................... 675
Hashtable<K,V> ........................................511 ImageView ................................................. 675
Haskell .......................................................592 immutable .......................................... 295, 307
hasNext() ....................................................739 implements ................................................. 460
Iterator<E> .............................................496 Implizite Typumwandlung......................... 139
hasNextBigDecimal().................................739 Import
hasNextBigInteger() ...................................739 Alle Typen eines Paketes ....................... 344
hasNextDouble() ........................................739 Einzelner Typ......................................... 343
hasNextInt() ...............................................739 Statische Mitglieder ............................... 344
hasPrevious() import-Deklaration ...................................... 90
ListIterator<E> .......................................498 incrementAndGet() .................................... 798
HBox ..........................................................669 indeterminate ............................................. 681
header() indexOf()
HttpRequest.Builder...............................891 List<E> .................................................. 489
Header-Dateien ............................................32 String...................................................... 299
headMap() InetAddress ................................................ 895
SortedMap<K,V>...................................519 InetSocketAddress ..................................... 898
headSet() InflaterInputStream .................................... 721
SortedSet<E> .........................................507 information hiding ..................................... 214
Heap ...................................100, 206, 233, 766 Information Hiding .................................... 196
heigher() Inherited ..................................................... 474
NavigableSet<E> ...................................509 init()
heigherEntry() JavaFX ................................................... 623
NavigableMap<K,V>.............................520 Initialisierer
heigherKey() statische.................................................. 248
NavigableMap<K,V>.............................519 Initialisierung ............................................. 108
Heimatverzeichnis ......................................697 Initialisierungsliste ..................................... 308
Hexadezimalsystem ...................................115 Initialisierungslisten ................................... 290
hgap ............................................................665 Initializable ................................................ 639
hgrow .................................................667, 669 initialize() ................................................... 639
High-Level - Binding-API .........................651 Injektion ..................................................... 638
High-Level Binding-API............................676 Innere Ausnahme ....................................... 555
Hollywood-Prinzip .....................................614 Innere Klasse ............................................. 255
Hostname ...................................................895 InputMismatchException ........................... 739
HTTP InputStream........................................ 552, 720
Request-Header ......................................876 InputStreamReader ............................ 735, 873
Response-Header ...................................876 insert()
HttpClient ...........................................885, 886 StringBuilder.......................................... 304
HttpRequest ........................................885, 887 Insets .................................. 665, 667, 669, 671
HttpRequest.BodyPublisher .......................885 instanceof ........................... 405, 421, 462, 492
HttpResponse.BodyHandler<T> ................885 Instanzinitialisierer ............................ 238, 652
HttpResponse<T> ..............................885, 888 Instanzvariablen ................................. 100, 206
HttpsURLConnection.................................879 int ............................................................... 102
HttpURLConnection ..................................878 IntBinaryOperator ...................................... 602
I IntConsumer .............................................. 600
IBM850 ......................................................726 Integer ........................................................ 307
ICMP ..........................................................866 IntegerProperty .......................................... 643
IEEE-754 ............................................104, 155 IntelliJ
if-Anweisung ..............................................157 Ausführungskonfiguration ............... 80, 167
If-Modified-Since.......................................876 Pakete ..................................................... 337
IllegalArgumentException .................558, 567 IntelliJ IDEA ........................................ 35, 123
940 Index

Installieren ................................................46 isSymbolicLink() ....................................... 701


IntelliJ-Modul...............................................54 isUpperCase() ............................................ 313
Interface .............................................447, 481 isWhitespace() ................................... 313, 739
Intermediäre Operationen ..........................592 isWritetable() ............................................. 702
intern() ........................................................301 Iterable<E> ....................................... 484, 496
Interner String-Pool....................................295 Iterable<T> ........................................ 179, 482
Internet Control Message Protocol ............866 iterate() ....................................................... 590
Interpreter .....................................................24 iterator()
interrupt() ...................................................802 Iterable<E> ............................................ 484
InterruptedException ..536, 539, 769, 800, 850 Iteratoren .................................................... 496
Interrupt-Signal ..........................................802 J
ints() ...........................................................591 Jackson ....................................................... 752
IntStream ....................586, 590, 591, 601, 602 Jakarta EE .................................................. 386
IntUnaryOperator .......................................591 jar ............................................................... 372
invalidated() jar.exe......................................................... 349
InvalidationListener ...............................647 jar-Dateien ......................................... 347, 370
InvalidationListener ...................................647 Java Collections Framework.............. 479, 842
Invarianz.....................................................423 Java Development Kit.................................. 24
invoke() ......................................................815 Java Enterprise Edition .............................. 864
IOException ...............................545, 718, 731 Java FX Packager ...................................... 358
iOS ...............................................................28 Java Platform Module System ................... 332
iPadOS .........................................................28 Java Runtime Environment.......................... 25
IP-Adresse ..................................................895 java.base..................................................... 362
IP-Datagramme ..........................................866 java.exe .................................................. 18, 42
IP-Protokoll ................................................866 java.io......................................................... 689
IPS ................................................................23 java.lang ............................................... 90, 332
IPv4 ............................................................866 java.lang - Paket......................................... 131
IPv6 ............................................................866 java.net ....................................................... 870
isAbsolute() ................................................698 java.se ................................................ 361, 385
isBound()....................................................649 javaagent .................................................... 382
isCompletedExceptionally().......................833 JavaBeans .................................................. 642
isDaemon() .................................................860 javac.exe ................................................ 24, 40
isDigit() ......................................................313 javadoc ................................................. 87, 334
isDirectory() ...............................................701 JavaFX ................................. 71, 119, 613, 845
File .........................................................707 JavaFX Application Thread ....................... 845
isDone() ..............................................811, 821 JavaFX Script............................................. 615
CompletableFuture<T> ..........................890 Java-Memory-Model ................................. 775
isEmpty() javap ........................................................... 237
Collection<E> ........................................484 javap.exe .................................................... 921
Map<K,E> .............................................514 Java-SE - API ............................................ 386
isExecutable().............................................702 Java-Speichermodell .................................. 775
isInfinite()...................................................153 javaw.exe ............................................. 19, 355
isInterrupted().............................................802 Jazelle DBX ................................................. 24
isLetter() .....................................................313 Jazelle DBX ................................................. 28
isLetterOrDigit() ........................................313 JCF ............................................................. 842
isLowerCase() ............................................313 jdeps ........................................................... 385
isNaN() .......................................................153 JDK .............................................................. 24
ISO Latin-1 ................................................726 JEE ............................................................... 27
ISO8859_1 .................................................726 jlink .................................................... 359, 382
ISO-8859-1.................................................726 JME .............................................................. 28
isReadable() ...............................................702 jmod ........................................................... 387
isRegularFile() ...........................................701 join()
Index 941

ForkJoinTask<T> ...................................817 Lambda-Ausdrücke.................................... 569


Thread ....................................................800 last()
joining()......................................................606 SortedSet<E> ......................................... 507
JOptionPane ...............................................184 lastEntry()
JPEG...........................................................675 NavigableMap<K,V> ............................ 519
JPMS ..........................................................332 lastIndexOf()
JRE ...............................................................25 List<E> .................................................. 489
JSON ..........................................................752 lastKey()
JVM ..............................................................25 SortedMap<K,V> .................................. 518
K lastModified() ............................................ 708
Kanonischer Konstruktor ...........................321 Latin-1........................................................ 726
Keep Alive .................................................875 launch() ...................................................... 621
KeyIterator .................................................257 layout constraints ....................................... 666
keySet() Layoutmanager .......................................... 664
Map<K,V> .............................................513 lazy ............................................................. 593
Klasse .....................................................1, 195 Leere Anweisung ....................................... 156
abstrakte .................................................408 Leertaste ..................................................... 677
Syntaxdiagramm ......................................82 length
Klassen Array .............................................. 283, 287
lokale ......................................................259 length()
versiegelte ..............................................413 File ......................................................... 708
klassenbezogene String...................................................... 298
Konstruktoren.........................................248 StringBuilder.......................................... 304
Methoden ...............................................247 Lesbarkeit (Modul) .................................... 361
klassenbezogene Variablen ........................244 lexical scoping ........................................... 582
Klassenliterale ............................................421 limit() ................................................. 591, 595
Klassenmethode ...........................................38 LineNumberReader.................................... 735
Klassenvariablen ........................................100 lines() ................................................. 592, 755
Kollektionen ...............................................479 lineSeparator() ........................................... 727
Kommentar...................................................86 LinkedBlocking¬Queue<E> .............. 785, 844
Dokumentationskommentar .....................87 LinkedHashMap<K,V> ............................. 517
Zeilenblock in IntelliJ auskommentieren .86 LinkedHashSet<E> .................................... 503
Zeilenrest ..................................................86 LinkedList<E> ................................... 493, 523
Komponenten .............................................613 Links-Shift-Operator.................................. 138
Komposition .......................................252, 411 Liskovsches Substitutionsprinzip .............. 409
Konditionaloperator ...................................144 List<E> ...................................................... 488
Konstanten .................................................245 ListChangeListener<E> ............................. 655
Konstruktoren.....................................235, 392 Listen ......................................................... 487
Konstruktorreferenzen ...............................584 listFiles() .................................................... 709
Kontextabhängige Schlüsselwörter ..............89 listIterator()
Kontrollkästchen ........................................680 List<E> .................................................. 489
Kontrollstrukturen ......................................157 ListIterator<E> .......................................... 498
Konvertierung ListView<String> ...................................... 626
einschränkende .......................................140 Literale ............................................... 114, 421
Kotlin ...........................................................28 Little-Endian .............................................. 726
Kovarianz ...........................................286, 423 Live Templates ............................................ 58
Kurzschlussauswertung ..............................138 Lock ........................................................... 780
L lock() .......................................................... 780
Label...........................................................674 lockInterruptibly()...................................... 781
Lacy Execution Logarithmus
Binding<T> ............................................651 dualer ..................................................... 567
Ladungsfaktor einer Hashtabelle ...............503 Logikfehler .................................................. 45
942 Index

Logische Operatoren ..................................136 Metainformationen .................................... 468


Logisches ODER........................................137 Method ....................................................... 472
Logisches UND ..........................................136 Method Area .............................. 100, 214, 245
Lokale Klassen ...........................................259 Methode
Lokale Variablen ........................................100 abstrakte ................................................. 407
long.............................................................102 statische.................................................... 38
longs() ........................................................591 Syntaxdiagramm ...................................... 83
LongStream ................................................590 Methoden ................................................... 213
loopback-Adresse .......................................900 Aufruf .................................................... 222
lower bound Definition ............................................... 214
Wildcard-Typen .....................................440 Parameter ............................................... 217
lower() rekursive ................................................ 250
NavigableSet<E> ...................................509 Rückgabewert ........................................ 216
lowerEntry() statische.................................................. 247
NavigableMap<K,V>.............................520 Überladen ............................................... 230
lowerKey() Methodenreferenzen .................................. 582
NavigableMap<K,V>.............................520 Microsoft...................................................... 37
Low-Level - Binding-API ..........................652 MIME-Typ ................................................. 752
LSP .............................................................409 MIME-Type ............................................... 755
M min()
MAC-Adresse ............................................865 Collections ............................................. 523
main() .....................................................10, 78 MIN_VALUE
Main-Class .................................................351 Double.................................................... 313
MalformedURLException .........................873 Mitgliedsklasse .......................................... 255
Manifest .....................................................351 mkdir()
MANIFEST.MF .........................................357 File ......................................................... 708
map() ..................................................592, 597 mkdirs()
Map<K,V> .................................................511 File ......................................................... 708
mapToInt() .................................................597 MKLNK ..................................................... 696
margin ........................................667, 669, 671 Model-View-Controller - Konzept ............ 630
Marker Interface .........................................460 Modul
Marker-Annotation.....................................471 IntelliJ ...................................................... 54
Marker-Annotationen .................................469 Java (JPMS) ........................................... 358
Maschinencode.............................................23 Modularisierung ................................. 196, 197
Math ...........................................131, 152, 290 Moduldeskriptor ................................ 360, 367
Maven.........................................................618 module-info.java ........................................ 360
max() Modulgraph................................................ 385
Collections .............................................523 Modulo ....................................................... 129
MAX_VALUE ...........................................153 Modulpfad.................................................. 368
Double ....................................................313 Monitor ...................................................... 776
Mehrdimensionale Arrays ..........................291 move
Mehrfachvererbung ............................412, 452 Files........................................................ 704
Mehrzeilenkommentar .................................86 move() ........................................................ 704
Member ..................................................5, 195 Multi-Catch - Block ................................... 534
Memory Flush ....................775, 778, 783, 797 Multitasking ............................................... 765
memory leaks .....................................241, 257 Multithreading ............................. 32, 765, 902
Memory Refresh ................775, 778, 783, 797 Murphy’s Law ........................................... 529
Mengen.......................................................498 Mustervariable ........................................... 171
Differenz ................................................499 Mustervergleich
Durchschnitt ...........................................499 switch ..................................................... 171
Vereinigung ............................................499 MVC .......................................................... 630
Meta-Annotationen ....................................474
Index 943

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

Inhalt auflisten ab Java 7 ........................703 PipedReader ....................................... 735, 788


Inhalt auflisten in Java 6 ........................709 PipedWriter ........................................ 725, 788
löschen ab Java 7....................................705 Pipeline ...................................................... 587
löschen in Java 6 ....................................710 Platform ..................................................... 846
umbenennen ab Java 7 ...........................704 Plusoperator ......................................... 92, 185
umbenennen in Java 6 ............................710 PNG ........................................................... 675
Orientierung von Operatoren .....................145 pollFirst()
OSI-Modell ................................................864 NavigableSet<E> ................................... 509
out-Objekt in System ...................................87 pollFirstEntry()
OutOfMemoryError ...................................549 NavigableMap<K,V> ............................ 519
OutputStream .............................................711 pollLast()
OutputStreamWriter ...................................725 NavigableSet<E> ................................... 509
Override .............................399, 461, 469, 473 pollLastEntry()
P NavigableMap<K,V> ............................ 519
Package Splitting........................................369 Polymorphie....................................... 200, 406
package-Deklaration ..................................333 pom.xml ..................................................... 621
package-info.java .......................................334 pop()
packages .....................................................331 Deque<E> .............................................. 523
padding ...............................................637, 665 Port ............................................................. 867
Pakete .........................................................331 Portabilität.................................................... 30
java.lang .................................................131 Positionsparameter ............................. 218, 223
Pane ............................................................625 POSITIVE_INFINITY .............................. 567
PAP ............................................................157 Double.................................................... 313
parallel() .............................................590, 603 POST
parallelStream() CGI-Parameter ....................................... 883
Collection<E> ................................484, 589 Postinkrement bzw. -dekrement ................ 129
Parametrisierter Typ ...................................420 Postinkrementoperator ............................... 127
Parent .........................................................622 Potenzfunktion ........................................... 131
parseDouble() .............................................311 pow() .......................................................... 131
parseInt() ....................................................166 PowerShell ................................................. 368
parseLong() ................................................185 Präinkrement bzw. -dekrement .................. 129
Parsen .........................................................739 Präprozessor ................................................. 32
Pascal .........................................................202 Predicate<T> ............................................. 594
Pascal Casing .............................................205 Preemtives Zeitscheibenverfahren ............. 805
PasswordField ............................................680 prefWith ..................................................... 670
Passwörter ..................................................680 Preview ...................................................... 174
Path.............................................................697 previous()
PATH ListIterator<E> ...................................... 498
Umgebungsvariable .................................40 previousIndex()
Paths ...........................................................697 ListIterator<E> ...................................... 498
pathSeparatorChar Primitive Datentypen ........................... 98, 102
File .........................................................707 Primzahlen ................................................. 183
pattern matching print() ........................................................... 91
switch .....................................................171 printf() .......................................... 92, 222, 718
peek() .........................................................599 PrintWriter ............................................. 731
permits ................................................413, 467 println() ........................................................ 91
Pflichtenheft ...............................................482 printStackTrace() ....................... 532, 533, 542
Phaser .........................................................795 PrintStream ................................................ 718
PHP ............................................................881 PrintWriter ......................................... 720, 731
ping.............................................................866 Prinzip einer einzigen Verantwortung197, 272
PipedInputStream ...............................721, 787 Prioritäten .................................................. 804
PipedOutputStream ............................711, 787 private ........................................................ 208
Index 945

Private Methoden readAttributes() .......................................... 701


in Schnittstellen ......................................457 readObject() ....................................... 745, 749
probeContentType() ...................................755 ReadOnlyIntegerProperty .......................... 644
Processor<T,R> .........................................853 ReadOnlyIntegerWrapper .................. 644, 645
Produktivität ...................................................4 Reaktive Ströme................................. 851, 885
Programmablaufplan ..................................157 record ......................................................... 202
Programmargumente ..................................165 Records ...................................................... 319
Programmierfehler .....................................552 RecursiveAction ........................................ 815
ProgressBar ................................................849 RecursiveTask<T>..................................... 815
Properties ...........................................410, 642 Red Hat .................................................. 13, 37
Property<T> ...............................................649 reduce() .............................................. 593, 600
PropertyChangeSupport .............................643 ReentrantLock............................ 537, 781, 806
protected .............................................391, 396 ReentrantReadWriteLock .......................... 781
Protocol Buffers .........................................752 Refaktorieren ............................................... 61
Protokoll .....................................................864 Referenzliteral............................................ 119
provides (JPMS).........................................363 Referenzparameter ..................................... 219
Pseudozufallszahlengenerator ....................289 Referenztypen .............................................. 99
public Referenzvariablen ...................................... 232
Klassenmodifikator ..................................38 Reflexion.................................... 364, 469, 471
Publisher<T>..............................................852 Region ........................................................ 665
Puffergröße.................................................716 Regulärer Ausdruck ................................... 300
Pufferung Reifizierbarer Typ ...................................... 425
BufferedOutputStream ...........................716 Rekursive Methoden .................................. 250
Punktoperator .....................................210, 223 Rekursive Typeinschränkung .................... 433
push() remove()
LinkedList<E> .......................................522 Collection<E> ........................................ 484
PushbackInputStream.................................721 Iterator<E>............................................. 497
PushbackReader .........................................735 List<E> .................................................. 489
put() Map<K,V> ............................................. 512
Map<K,V> .............................................511 Queue <E> ............................................. 522
putAll() removeAll()
Map<K,V> .............................................511 Collection<E> ........................................ 484
Q Set<E> ................................................... 499
Qt ..................................................................30 removeEventFilter() ................................... 662
Quellcode .......................................................7 removeEventHandler() .............................. 663
QUERY_STRING .....................................881 removeFirst()
Queue<E> ..................................................522 Deque<E> .............................................. 522
Quick-Fixes ..................................................57 removeIf()
R Collection<E> ........................................ 484
Race Condition ...........................................773 removeLast()
RadioButton ...............................................680 Deque<E> .............................................. 522
Radioschalter ..............................................682 removeListener()........................................ 647
Random ......................................249, 289, 591 renameTo()
random() .....................................................290 File ......................................................... 710
RandomAccess ...........................................492 replace()
range() ........................................................590 String...................................................... 300
rangeClosed() .............................................590 replace()
read() StringBuilder.......................................... 304
FileInputStream ......................................721 replace()
readability (module) ...................................361 Map<K,V> ............................................. 512
readAllBytes() ............................................753 Request/Response - Muster ....................... 876
readAllLines() ............................................753 requestFocus()............................................ 677
946 Index

requires (JPMS)..........................................360 scope .......................................................... 111


Reservierte Schlüsselwörter .........................89 Seiteneffekte .............................................. 610
resolve() .............................................698, 700 selected ...................................................... 680
resolveSibling() ..................................698, 704 Selection Sort ............................................. 325
Ressourcen freigeben .................................563 Semantikfehler ............................................. 45
REST ..........................................................863 Semaphore ................................................. 789
Restmantisse...............................................105 sendAsync() ............................................... 889
resume() .....................................................801 SequenceInputStream ................................ 721
retainAll() Serializable ................................ 448, 460, 743
Collection<E> ........................................484 serialVersionUID ....................................... 744
Set<E>....................................................499 Serienparameter ......................................... 221
Retention ............................................470, 474 ServerSocket .............................................. 900
RetentionPolicy ..........................................470 Service<V> ................................................ 847
return ..................................................165, 183 ServiceLoader ............................................ 363
return-Anweisung.......................................216 set()
Returncode .........................................532, 543 List<E> .................................................. 489
reverse() ListIterator <E> ..................................... 498
Collections .............................................524 Set<E> ....................................................... 499
Robert C. Martin ........................................197 setAlignment() ........................................... 679
Robustheit ....................................................31 setAllowIndeterminate() ............................ 680
Rohtyp ........................................308, 418, 420 setConnectTimeout .................................... 878
Rot-Schwarz -Architektur ..........................505 setConnectTimeout() ................................. 878
Round-Robin ..............................................805 setContentDisplay() ................................... 675
Router .........................................................866 setDaemon() ............................................... 860
RowConstraints ..........................................667 setDefaultButton() ..................................... 677
rowIndex ....................................................666 setDoOutput() ............................................ 884
rowSpan .....................................................667 setEditable() ............................................... 680
RS-232 .......................................................865 setFont() ..................................................... 681
rt.jar ............................................................350 setFontSmoothingType() ........................... 893
Rückgabewert.............................216, 532, 543 setGraphic() ............................................... 675
Run - Fenster ................................................62 setGraphicTextGap() ................................. 675
runAsync() .................................................820 setHgap().................................................... 665
runLater() ...................................................846 setIfModifiedSince() .................................. 877
Runnable ............................................772, 810 setImage() .................................................. 675
RuntimeException ......................................551 setLastModified() ...................................... 709
S setLastModifiedTime() .............................. 702
SafeVarargs ................................................474 setMnemonicParsing() ............................... 676
Scala ...........................................................592 setOnAction() ............................ 573, 627, 676
Scanner ...............................120, 491, 537, 739 setOnScroll() .............................................. 905
Scene ..........................................................624 setOut() ...................................................... 720
Scene Builder .....................................265, 633 setPadding() ............................................... 665
installieren ................................................71 setPriority() ................................................ 804
scheduleAtFixedRate()...............................813 setRequestMethod() ................................... 878
ScheduledService<V>................................847 setRequestProperty().................................. 877
ScheduledThreadPoolExecutor ..................812 setResizable() ............................................. 628
Scheduler ....................................................804 setSoTimeout() .......................................... 899
Schleife.......................................................175 setter ........................................................... 214
Schnittstelle ................................196, 447, 481 setText() ..................................................... 675
Schnittstellen setVgap().................................................... 665
versiegelte ..............................................467 setVgrow() ................................................. 666
Schriftauszeichnung ...................................680 setWritable() .............................................. 709
Schwache Konsistenz eines Iterators .........843 setZoom() ................................................... 905
Index 947

short ............................................................102 Stack .................................. 100, 206, 222, 766


showConfirmDialog() ................................186 Überlauf ................................................. 252
showInputDialog() .....................................185 Stack Frame ............................................... 227
showMessageDialog() ................................185 stack trace .................................................. 542
shuffle() ......................................................441 Stack<E> ................................................... 523
Collections .............................................524 StackOverflowError ........................... 549, 917
shutdown() .........................................812, 813 StackPane ................................................... 673
Sicht ...........................................................513 Standard Widget Toolkit............................ 616
SortedSet<E> .........................................507 Standardausgabe .................................. 91, 542
Sichtbarkeitsbereich ...................................206 Standardausgabestrom ............................... 718
lokale Variablen .....................................111 Standarddialoge ......................................... 184
Sieb des Eratosthenes .................................325 Standardfehlerausgabe ............................... 542
signal() ...............................................783, 801 Standardfehlerausgabestrom ...................... 718
signalAll() ..................................................783 Standardkonstruktor ................................... 235
Signatur ..............................................230, 398 Standardpaket .............................. 90, 121, 335
SimpleDateFormat .....................................877 Standardschalter ......................................... 677
SimpleIntegerProperty .......................643, 649 Stapel ......................................................... 522
Simput ........................121, 138, 191, 406, 741 start() .......................................................... 621
sin() ............................................................281 Thread .................................................... 768
Single-Catch - Block ..................................534 Startfähige Klasse ........................................ 78
Single-Responsibility - Prinzip ..........197, 272 Startklasse .................................................... 10
Singleton-Pattern........................................195 startsWith()
size() ...........................................................701 String...................................................... 299
Collection<E> ........................................484 starvation.................................................... 805
Map<K,V> .............................................514 static ........................................................... 244
Skalarprodukt .............................................281 bei Mitgliedsklassen .............................. 258
Skalierbarkeit .............................................843 Statische
sleep() .........................................................769 Felder ..................................................... 244
Smalltalk ....................................................196 Initialisierer ............................................ 248
SmartScreen .................................................15 Mitgliedsklasse ...................................... 258
SMTP-Server .............................................868 Statische Methode ........................................ 38
SOAP .........................................................863 Statische Methoden
Socket .................................................867, 896 in Klassen............................................... 247
Klasse .....................................................898 in Schnittstellen ..................................... 456
SocketAddress ............................................898 Steuerelemente ........................................... 613
SocketTimeoutException ...................878, 899 stop() .......................................................... 802
sort() ...........................................................448 JavaFX ................................................... 623
Arrays .....................................................288 Strategie-Entwurfsmuster .......................... 572
Collections .............................................523 stream()
sorted() ...............................................592, 598 Arrays .................................................... 590
SortedList<> ..............................................627 Collection<E> ........................ 484, 586, 589
SortedMap<K,V>.......................................518 Stream<T> ........................................ 585, 597
SortedSet<E> .............................................506 StreamEncoder ........................................... 728
Sortieren Streams ...................................................... 689
von Strings .............................................298 strictfp ........................................................ 155
Sortieren durch Auswahl ............................325 String.......................................................... 294
Spätes Binden .............................................406 hashCode() ............................................. 503
Speicherkonsistenzfehler ...........................775 StringBuffer ............................................... 303
Speicherlöcher ....................................241, 257 StringBuilder.............................................. 303
Spring-Framework .......................................27 String-Pool ................................................. 295
sqrt() ...........................................................280 StringProperty ............................................ 645
Stabilität .........................................................4 StringReader .............................................. 735
948 Index

StringTableSize ..........................................301 System.in ................................................... 552


StringTokenizer ..........................329, 888, 896 System.out ................................................... 87
StringWriter ...............................................725 Szenengraph ............................................... 624
struct ...........................................................202 T
Struktogramm.............................................251 tailMap()
Strukturiertes Programmieren ....................201 SortedMap<K,V> .................................. 519
subList() tailSet()
List<E> ..................................................489 SortedSet<E> ......................................... 507
subMap() take() .......................................................... 785
SortedMap<K,V>...................................519 takeWhile() ................................................ 595
SubmissionPublisher<T> ...........................854 Target ......................................................... 475
submit() ..............................................810, 811 Task<V> ............................................ 847, 848
subpath() TCP ............................................................ 866
Path.........................................................698 TCP-Flags .................................................. 870
subscribe() ..................................................852 TCPView ................................................... 874
Subscriber<T>............................................852 Terminale Operationen .............................. 593
subSet() test() ........................................................... 594
SortedSet<E> .........................................508 Textblöcke ................................................. 305
Substitutionsprinzip ...................................409 TextField .................................................... 678
substring() TextInputControl ....................................... 678
String ......................................................299 thenAccept() .............................................. 827
subtract() ....................................................676 thenAcceptAsync() .................................... 827
sum() ..................................................590, 604 thenApply() ........................................ 823, 825
super thenApplyAsync()...................................... 826
Basisklassenkonstruktor .................395, 397 thenCombine() ........................................... 829
überdecktes Feld ....................................403 thenCombineAsync() ................................. 829
überschriebene Methode ........................400 thenRunAsync() ......................................... 823
Superklasse.................................................394 this.............................. 210, 224, 237, 240, 243
Supplier<T> ...............................................821 Thread ........................................................ 767
supplyAsync() ............................................821 Thread-Gruppen ......................................... 860
SuppressWarnings ..............................429, 474 ThreadLocalRandom ................................. 290
suspend() ....................................................801 Threadpool ......................................... 807, 902
switch .........................................................315 Thread-Sicherheit
switch-Anweisung ......................................163 bedingte.................................................. 843
switch-Ausdruck ........................................169 throw .......................................................... 554
SWT ...........................................................616 throws ................................................ 555, 557
symbolische Links......................................701 ThumbEE ..................................................... 28
Symbolische Links .....................................696 Tiefe Objektkopie .............. 712, 721, 742, 751
Synchronisierter Block ...............................777 Time To Live ............................................. 866
synchronized ..............................................776 Time Wait .................................................. 875
synchronizedCollection() timeout()
Collections .............................................524 HttpRequest ........................................... 887
synchronizedList() .............................493, 842 Timer.......................................................... 812
Collections .............................................524 Time-To-Live ............................................ 870
synchronizedMap() ....................................511 toArray() .................................................... 607
Collections .............................................524 Collection<E> ................................ 484, 485
synchronizedMap) ......................................842 toCharArray() ............................................ 300
synchronizedSet()...............................501, 842 TOCTTOU ................................................. 699
Collections .............................................524 toDegrees() ................................................ 281
Syntaxdiagramm ..........................................80 toFile() ....................... 699, 713, 722, 729, 737
Syntaxfehler .................................................45 ToggleGroup .............................................. 682
System.err ..................................................718 Token ......................................................... 329
Index 949

Tokens ........................................................739 Unboxing ................................................... 309


toList() ........................................................605 unchecked exceptions ................................ 551
toLower() ...................................................166 Undefinierte Werte .................................... 153
toLowerCase() ............................................300 Unendlich ................................................... 152
Top-Level - Klassen ...................................254 Ungeprüfte Ausnahmen ............................. 551
Top-Level - Typen .....................................389 Unicode ...................................................... 687
toRadians() .................................................281 Unicode - Escape-Sequenzen .................... 118
toString() UnicodeBigUnmarked ............................... 726
StringBuilder ..........................................304 UnicodeLittleUnmarked ............................ 726
toUpperCase() ............................................300 Unicode-Zeichensatz ................................... 89
toUri().........................................................699 Unified Modeling Language .......................... 5
Path.........................................................699 Uniform Resource Identifier ...................... 887
Transformationsklassen .............................692 Uniform Resource Locator ........................ 871
transient ......................................................747 Unit Testing ............................................... 198
Transmission Control Protocol ..................866 unmodifiableCollection()
TreeMap<K,V> ..........................................520 Collections ............................................. 524
TreeSet<E> ........................................462, 505 unmodifiableList()
trimToSize() ...............................................492 Collections ............................................. 524
try with resources ...............................565, 695 unmodifiableMap()
try-catch-finally ..........................................533 Collections ............................................. 524
tryLock() ....................................................781 unmodifiableSet()
TTL ............................................................870 Collections ............................................. 524
Typ unnamed package....................................... 335
parametrisierter ......................................420 Unterbrechungspunkt ................................. 226
Typanpassung Unterlauf .................................................... 154
erweiternde .....................................128, 139 Unterpakete ................................................ 335
Typformalparameter...........................427, 436 Unterprogramme ........................................ 201
Typgenerizität ............................................417 upper bound
Typinferenz ..........................97, 109, 420, 436 Wildcard-Typen ..................................... 440
Typlöschung ...............................................420 Upper bound
Typsicherheit ........................................96, 418 Typparameter ......................................... 420
Typumwandlung URI .................................................... 699, 887
Automatische .........................................139 URL ........................................................... 872
Explizite .................................................140 URL-Codierung ......................................... 883
U URLConnection ......................................... 876
Überdecken URLEncoder .............................................. 883
von statischen Methoden ........................401 US-ASCII .................................................. 726
Überdeckte Felder ......................................403 useDelimiter() ............................................ 739
Überladen User Datagram Protocol ............................ 867
von Methoden ........................................230 uses (JPMS) ............................................... 363
von Operatoren .......................................297 usingProxy() .............................................. 878
Überlauf .....................................................150 UTF-16BE ................................................. 726
Überschreiben UTF-16LE.................................................. 726
von Instanzmethoden .....................398, 556 UTF-8 ........................................................ 726
UDP ............................................................867 V
UI-Thread ...................................................845 valignment ................................................. 667
UML ...............................................................5 value() ........................................................ 470
Umschalter .................................................680 valueOf() .................................................... 307
Unäre Operatoren .......................................127 Double.................................................... 311
Unbenanntes Moduls..................................384 String.............................................. 185, 312
unbind() ......................................................649 values()
unbindBidirectional() .................................649 Enumerationen ....................................... 316
950 Index

Map<K,V> .............................................514 wasReplaced()............................................ 655


var.........................................97, 109, 113, 234 wasUpdated() ............................................. 656
Varargs-Parameter .....................................221 Webanwendungen...................................... 863
variable arity methods ................................221 Webdienste ................................................ 863
Variablen ......................................................94 WebEngine ................................................ 892
finalisierte...............................................113 WebKit ....................................................... 892
globale ....................................................101 WebView ................................................... 892
lokale ......................................................100 Wertzuweisung .......................................... 110
Variablendeklaration ..................................108 whenCompleteAsync() .............................. 838
VBox ..........................................622, 634, 669 while-Schleife ............................................ 180
VCS ..............................................................63 WhiteSpace ................................................ 739
Vector<E> ..................................................492 widgets ....................................................... 613
Verbundanweisung.............................111, 156 Wiederholungsanweisung .......................... 175
Vereinigung von zwei Mengen ..................499 Wildcard-Datentypen ................................. 438
Vererbung...................................................198 Beschränkung nach oben ....................... 439
Klassen ...................................................393 Beschränkung nach unten ...................... 440
Schnittstellen ..........................................452 Windows Latin-1 ....................................... 726
Vergleich ....................................................131 Windows-1252 ........................................... 726
Vergleichsoperatoren .................................131 Work Stealing ............................................ 815
Verketten von Strings.................................297 Worker.State .............................................. 847
Verkettete Liste ..........................................493 Worker<V> ................................................ 847
Verschiebungsformel .................................327 Wrapper-Klassen ....................................... 307
Versiegelte Klassen ....................................413 write()
Versiegelte Schnittstellen ...........................467 Files........................................................ 753
Version der JVM ..........................................13 Writer ..................................................... 725
Verzeichnis writeObject() ...................................... 745, 749
anlegen ...................................................700 Writer ......................................................... 724
anlegen in Java 6 ....................................707 WSDL ........................................................ 863
Inhalt auflisten ab Java 7 ........................703 X
Inhalt auflisten in Java 6 ........................709 XML-Namensräume .................................. 636
löschen ab Java 7....................................705 xmlns.......................................................... 636
löschen in Java 6 ....................................710 XML-Verarbeitungsinstruktionen ............. 636
umbenennen ab Java 7 ...........................704 -Xmx .......................................................... 549
umbenennen in Java 6 ............................710 Y
vgap ............................................................665 Year............................................................ 651
vgrow .................................................667, 669 yield
View switch ..................................................... 169
SortedSet<E> ........................................507 yield() ......................................................... 805
Virtuelle Java-Maschine ..............................24 Z
void.............................................................216 Zahlenkreis ................................................ 150
Void ............................................................431 Zeichenfolgenliterale ................................. 118
volatile........................................................797 Zeilennummern ............................................ 55
Vollduplex ..................................................897 Zeilenrestkommentar ................................... 86
Vollständige Ordnung ................................503 Zeilenumbruch ........................................... 328
Vorschaustatus ...........................................174 Zeitscheibenverfahren................................ 805
W ZIP-Dateiformat ......................................... 348
Wahrheitstafeln ..........................................136 ZipInputStream .......................................... 721
wait() ..................................................778, 801 ZipOutputStream ....................................... 712
Warteschlangen ..................................522, 785 Zufallszahlen.............................................. 289
wasAdded() ................................................655 Zugriffsmodifikatoren........................ 388, 391
wasPermutated().........................................655 protected ................................................ 396
wasRemoved() ...........................................655 Zugriffsrechte für Dateien ......................... 702
Index 951

Zugriffsschutz ............................................196 Zuweisungsoperator ................................... 141


Zusammengesetzte Anweisung ..................157 Zweierkomplement .................................... 150
Zustandsbehaftete Operationen ..................593 Zweistellige Operatoren............................. 127
Zustandslose Operationen ..........................593

Das könnte Ihnen auch gefallen