Neue I/O- und Netzwerkfunktionen in Java 1.4

Das N im I/O

Marc-Oliver Scheele

Mit der Java 2 Standard Edition (J2SE) 1.4. stehen dem Softwareentwickler neue Möglichkeiten zur Verfügung, wenn es um I/O-Zugriffe und Netzwerkfunktionen geht. Seit geraumer Zeit wird die Java-Plattform als Basis für Serversysteme genutzt, jedoch gab es immer wieder Stimmen, die mangelnde Skalierung und Performance beklagten und Erweiterungen im Java I/O forderten. Mit dem neuen Release hat Sun auf die Kritik reagiert und ein neues I/O-Modul in die Java-Plattform aufgenommen. Dieser Artikel zeigt anhand eines einfachen Programmbeispiels die neuen Konzepte der Netzwerkprogrammierung und einen Vergleich zur herkömmlichen Vorgehensweise.

Nachdem in der Ausgabe 2.2002 des Java Magazins das neue Paket java.nio vorgestellt wurde, soll es in diesem Beitrag hauptsächlich um dessen Netzwerk-Funktionalitäten gehen. Bereits im Januar 2000 wurde im Rahmen des Java Community Process eine Anfrage zur Erneuerung und Verbesserung der Java I/O-API formuliert. Sun selbst stellte den Java Specification Request 51 in die Community [1]. Gefordert wurden insbesondere besser skalierbare I/O Operationen für Datei- und Socket-Zugriffe, schneller Zugriff auf binäre Speicherbereiche besonders durch Memory Mapped Files sowie Parsing-Funktionen und bessere Konvertierung von zeichenorientierter I/O.
In der Folge beteiligten sich bekannte Größen wie Bea Systems, IBM, Oracle und Mitglieder der Apache-Group an der Spezifikation und deren Umsetzung. Am 17. Dezember 2001 wurde das Ergebnis dann von den zuständigen Komitee-Mitgliedern verabschiedet und fand seinen festen Platz im Standard-API von Java.
In diesem Beitrag wird der Schwerpunkt auf dem neuen Konzept für skalierbaren I/O Operationen liegen. Hier wird insbesondere der asynchrone und nicht blockierende Einsatz von Sockets untersucht. Eine Ausführung über die Memory Mapped Files ist in [2] zu finden. Mit den Neuerungen der zeichenorientierten I/O und den Parsing-Funktionen mittels regulärer Ausdrücke wird sich ein zukünftiger Artikel des Java Magazins beschäftigen.

Das neue I/O

Sun hat für die neue I/O API ein Package namens java.nio der Plattform hinzugefügt. Das n‘ steht dabei wirklich für new‘ und soll die Neuartigkeit zum Ausdruck bringen. Man darf durchaus gespannt sein, ob Sun diese Namensgebung in einigen Jahren noch für richtig hält… Das java.nio-Package besteht aus weiteren vier Sub-Packages. Zählt man das Package java.util.regex und die notwendigen Erweiterungen an existierenden Klassen mit, so wurden insgesamt 85 Klassen, Interfaces und Exceptions für das neue I/O API hinzugefügt bzw. modifiziert.
Das bereits etablierte API java.io hat Sun, außer wenigen Schnittstellen zum neuen I/O, im Wesentlichen unverändert gelassen. Es wurde also nicht deprecated‚ und soll parallel weiter existieren. Sun geht davon aus, dass sowohl der alte wie auch der neue Ansatz in Zukunft seine Einsatzfelder finden.
Der wesentliche Unterschied zwischen beiden Ansätzen liegt in deren unterschiedlicher Flexibilität. Während der Entwickler bei der Netzwerkprogrammierung mit dem alten I/O nicht viele Einflussmöglichkeiten auf das Verhalten von Netzwerkverbindungen hatte, bietet das neue I/O eine Vielzahl von Optionen. Hierdurch kann beispielsweise ein Serversystem entsprechend seines Einsatzes genau angepasst und dadurch schlanker und skalierbarer werden. Erreicht wurde diese neue Flexibilität, in dem das neue I/O auf weitere Betriebssystemroutinen (teils sehr direkt) zugreift. Folglich sind zum einen neue Funktionen wie beispielsweise non-blocking I/O-Zugriffe und zum anderen schnelle Operationen wie beispielsweise Memory Mapped Files möglich geworden.
Im Folgenden wollen wir ein wenig ins Detail gehen und uns die Neuerungen anhand eines kleinen Client/Server Programms anschauen. Hierzu betrachten wir zunächst den herkömmlichen Ansatz mit dem alten I/O, um daraufhin eine Umsetzung mit dem neuen Ansatz zu untersuchen.

Client/Server Progrämmchen

Das Beispielprogramm besteht aus einem einfachen Client/Server Echo System: Ein Client schickt dem Server eine Nummer und der Server antwortet dem Client mir der gleichen Nummer. Des Weiteren simulieren wir im Server ein wenig Bearbeitungszeit. Diese Bearbeitungszeit kann im realen Einsatz beispielsweise eine rechenintensive Hintergrundberechnung, diverse Aufräumarbeiten oder aber Bearbeitungen für Client-Anfragen darstellen.
Unsere Anforderung besagt, dass der Server nur aus einem einzigen Thread bestehen darf. Ziel ist es den Server so aufzubauen, dass er zum einen den Clients schnelle Antworten liefert und zum anderen freie Rechenzeit nutzt, um die erwähnten Hintergrundberechnungen durchführen zu können.
Zugegeben: In der Praxis wird es wohl kaum einen Single-Threaded-Server geben, da parallele Abläufe meist zwingend notwendig sind. Aber trotzdem kann man anhand dieses vereinfachten Beispiels gut die Vorteile des neuen I/Os aufzeigen und der Ansatz ist auf größere Systeme leicht übertragbar.

Der Client

Listing 1 zeigt unser Clientprogramm, welches wir sowohl zur Befragen des alten wie des neuen Server nutzen. Es erzeugt hundert Anfragen, die jeweils nacheinander alle viertel Sekunde abgeschickt werden. In der main()-Methode messen wir die Zeit, die für die Beantwortung der 100 Anfragen benötigt wird, um einen Vergleich zwischen beiden Servervarianten ziehen zu können. Die Client-Anfragen, die in der Methode sendRequests() generiert werden, sind mittels der bekannten Socket-Klasse implementiert. Es handelt sich bei der Socket-Erzeugung, beim Schreiben und beim Lesen also, um herkömmlich blockierende I/O-Aufrufe, die solange den Thread schlafen legen bis Antworten vorhanden sind.

Listing 1 – Simuliert 100 sequentielle Client-Anfragen

import java.net.*;
import java.io.*;

// a simple single threaded echo server with java 0)
Thread.sleep(100);
workTime -= 100;
}

public static void main(String[] args) throws Exception{
(new ServerOld()).start();
}
}

Der erste Lösungsansatz unseres Servers wird auf Grundlage von Java 1.3 implementiert und nutzt folglich das alte I/O API. Das Ergebnis finden Sie in Listing 2. Im Folgenden ein paar Erläuterungen und eine Analyse dazu:
Die Methode start() erzeugt einen ServerSocket, der für die Entgegennahme von Client-Anfragen zuständig ist. In einer Endlosschleife wird nun jeweils auf eine Client-Anfrage gewartet und dann mittels der Methode handleClientRequest() bearbeitet. Erwähnenswert ist die Methode doSomeWork(), die zwischen dem Eintreffen und der Verarbeitung der Client-Anfrage aufgerufen wird. Hier wird eine Bearbeitungszeit von insgesamt 10 Sekunden simuliert. Sie wird jeweils zu einer zehntel Sekunde pro Methodenaufruf abgearbeitet. Was passiert nun, nachdem der Server und das Clientprogramm gestartet werden?
Wie erwartet sendet unser Client sequentiell seine 100 Anfragen und unser Server antwortet pflichtbewusst mit einem Echo. Es ist festzustellen, dass für die Beantwortung aller Anfragen gute 37 Sekunden benötigt wurden, was pro Anfragezyklus etwa 0,37 Sekunden Bearbeitungszeit entspricht. Dieses Zeitintervall ist schnell erklärt: Der Client sendet alle 0,25 Sekunden eine Anfrage und der Server benötigt jeweils 0,10 Sekunden Bearbeitungszeit, so dass sich unter Berücksichtigung der Ausführungsdauer der eigentlichen I/O-Operationen in der Summe die obige Bearbeitungszeit ergibt.
Eine schnellere Bearbeitung des Servers wird leider durch folgende Zeile verhindert:

Socket clientSocket = serverSocket.accept();

In dem Package java.nio und seinen Subpackages sind im Wesentlichen vier Objekttypen erschaffen worden, um die neuen Funktionalitäten abzupictureen:

  • Buffer: Befinden sich im Package java.nio. Es handelt sich dabei um Container zum Schreiben und Lesen von Daten.
  • Charsets: Befinden sich im Package java.nio.charset. Es beinhaltet Klassen zur Abbildung und Umwandlung von Bytes in Unicode – und vice versa.
  • Channels: Befinden sich im Package java.nio.channels. Sie definieren einen bidirektionalen Kommunikationskanal zu I/O-Entitäten wie Sockets und Dateien.
  • Selectors: Befinden sich im Package java.nio.channels. Sie verwalten I/O-Events der Channels und ermöglichen multiplexed und non-blocking I/O-Operationen.

Die Buffer sind wohl am ehesten mit herkömmlichen Arrays und Collections wie die LinkedList zu vergleichen. Sie dienen dazu Daten abzupictureen, zu schreiben und zu lesen. Es handelt sich bei den Buffern um einen einfachen linearen und sequentiell arbeitenden Speicherbereich, der sehr speicherplatzfreundlich und performant verwaltet wird. Es existiert jeweils eine Buffer-Klasse für jeden nicht booleschen primitiven Typ, von ByteBuffer über FloatBuffer bis hin zum ShortBuffer. Geschrieben und gelesen kann sowohl relativ zur aktuellen Position oder absolut über entsprechende put() und get() Methoden. Jeder Buffer hat nach der Erzeugung eine fixe Kapazität, welche die maximal aufzunehmende Anzahl an Elementen angibt (capacity()). Weiter existiert ein Zeiger auf die aktuelle Lese- und Schreib-Position (position()) sowie eine Limit-Position, über die nicht hinaus gelesen und geschrieben werden darf (limit()) und weitere Service-Methoden. Besonders erwähnenswert ist die Klasse ByteBuffer, die im Gegensatz zu ihren Kollegen die Möglichkeit bietet über die statische Methode ByteBuffer.allocateDirect() einen direkten Speicher anzulegen und diesen auch über die vererbte Klasse MappedByteBuffer zu nutzen weiß. Bei einem so angelegen Buffer versucht die Virtuelle Maschine den Speicherbereich direkt über native Funktionen des Betriebssystems zu verwalten, was eine enorme Geschwindigkeit verspricht (siehe auch [2]).
Die fünf Klassen im Package java.nio.charset stellen den (nicht ersten) Versuch von Sun dar, die Konvertierungsprobleme zwischen Bytes und Unicode-Zeichen in den Griff zu bekommen. Enthalten ist die Klasse Charset, welche eine Zuordnung zwischen Unicode-Zeichen und Bytes definiert und die Erzeugung von CharsetDecoders und CharsetEncoders zur jeweiligen Umwandlung erlaubt.
Die Channels sind die Kommunikationskanäle zu Sockets, Dateien und Pipes. Die Channels schreiben und lesen generell aus einem ByteBuffer. In java.nio.channels sind sieben Channel-Interfaces spezifiziert, welche die verschiedenen Eigenschaften der Kanäle definieren: Ganz oben in der Vererbungshierarchie steht das Interface Channel, welches lediglich die Methoden close() und isOpen() definiert. Hieraus ist zu entnehmen, dass ein Channel lediglich die Zustände offen und nicht offen haben kann. Bei der Erzeugung ist ein Channel generell offen, und ist er einmal geschlossen worden, bleibt er es und kann nicht mehr für I/O Operationen verwendet werden. Abgeleitet von diesem Interface werden die Schnittstellen ReadableByteChannel und WriteableByteChannel. Im ersten ist eine Methode read(ByteBuffer) definiert, welche eine Anzahl von Bytes aus einem Channel in einen Buffer liest. Analog bietet der WriteableByteChannel eine write-Methode, um Bytes aus einem Buffer in einen Channel zu schreiben. Der ByteChannel vereint die Eigenschaften obiger Interfaces und definiert somit einen lese- und schreibfähigen Kanal. Bei dem ScatteringByteChannel handelt es sich um einen lesenden Kanal, der zusätzlich die Methode read(ByteBuffer[]) anbietet. Hierdurch wird es möglich, die gelesenen Bytes in einem Schritt in mehrere Buffer zu transferieren. Dies kann beispielsweise sehr nützlich bei der effizienten Implementierung von Netzwerk-Protokollen sein. Analog findet sich die Schnittstelle GatheringByteChannel, die aus mehren Buffern in einen Channel schreibt. Abschließend ist in diesem Package noch das Interface InterruptibleChannel definiert. Es handelt sich dabei um ein einfaches Markierungs-Interface, welches zeigt, dass ein Kanal von einem zweiten Thread während eines blockierenden I/O-Aufrufes geschlossen bzw. unterbrochen werden darf.
Im java.nio.channels-Package sind die obigen Interfaces für die geläufigen I/O-Entitäten implementiert worden. Die Klassen SocketChannel, SocketServerChannel und DatagrammChannel dienen der Kommunikation mit Netzwerk-Sockets. Analog finden sich Klassen für den Zugriff auf Dateien und Pipes. Erwähnenswert ist, dass für die Netzwerkzugriffe über Sockets blocking– und non-blocking-Operationen definiert werden können, was im übrigen eine der wesentlichen Neuerungen darstellt. Weitere Erläuterungen hierzu folgen im nachstehenden Beispielprogramm.
Da die Funktionalität der Channels mit denen der herkömmlichen Streams nahezu identisch ist, wird es in der Praxis oft gewünscht, Streams in Channels zu verwandeln und umgekehrt. Für diesen Zweck hat Sun die Klasse Channels bereitgestellt, die über statische Wrapper-Methoden eine Umwandlung ermöglicht.
Interessant im Zusammenhang mit den oben beschriebenen Channels ist die Frage, auf welche Art und Weise die Benachrichtigung von I/O-Events erfolgt. Wie wird beispielsweise das Eintreffen von Daten über eine Socketverbindung in Erfahrung gebracht? Aufgrund der Tatsache, dass die Aufrufe nicht mehr (zwingend) blockieren, muss die Abfrage der eingetroffenen Daten asynchron erfolgen können. Für diesen Zweck hat Sun die Selectors entwickelt – sie implementieren das so genannte Reactor-Pattern (siehe auch [3]). Dieses Pattern beschreibt wie eintreffende Events zeitlich von der Verarbeitung getrennt werden. Die Events können zu einer beliebigen Zeit eintreffen, werden aber nicht direkt weitergeleitet. Stattdessen sammelt der Reactor eintreffende Events und reicht sie erst bei Nachfrage an den so genannten Handler.
Die Rolle des Reactors übernimmt im Java-API die Klasse java.nio.channels.Selector. Hier kann sich jeder Channel, der von einem SelectableChannel abgeleitet ist, registrieren. Dieses geschieht über den Methodenaufruf register(Selector sel, int ops). Über den Parameter ops kann der Channel angeben, welche I/O-Events ihn interessieren. Dieses könnten beispielsweise Lese- und Schreib-Operationen sein (OP_READ, OP_WRITE). Die Registrierung zwischen Channels und einem Selector werden durch Objekte der Klasse java.nio.channels.SelectionKey repräsentiert. Diese Objekte können daraufhin befragt werden, ob bestimmte Ereignisse eingetroffen und somit Daten verfügbar sind. Auch hier folgen weitere Erläuterungen in dem nun folgenden Programmbeispiel.

Listing 3 – Der Single-Threaded Server neuem non-blocking I/O-API

import java.io.*;
import java.net.*;
import java.util.*;
import java.nio.*;
import java.nio.channels.*;


// a simple single threaded echo server with java.nio in java 1.4
public class ServerNew  {
private long workTime = 10000;

private Selector readSelector;
private ByteBuffer buffer = ByteBuffer.allocate(1);


public void start () throws Exception {
System.out.println("ServerNew started!");
SocketChannel client;

readSelector = Selector.open();
ServerSocketChannel channel = ServerSocketChannel.open();
ServerSocket socket = channel.socket();
socket.bind(new InetSocketAddress("localhost",8777));
channel.configureBlocking(false);

while(true) {
client = channel.accept();
doSomeWork();
if(client != null) {
client.configureBlocking(false);
client.register(readSelector,SelectionKey.OP_READ);
}
handleClientRequest();
}
}

public void handleClientRequest() throws Exception  {
Set keys;
Iterator it;
SelectionKey key;
SocketChannel client;

if(readSelector.selectNow() > 0) {
keys = readSelector.selectedKeys();
it = keys.iterator();

while(it.hasNext()) {
key = (SelectionKey)it.next();
if(key.isReadable()) {
client = (SocketChannel)key.channel();
buffer.clear();
client.read(buffer);
System.out.println("'"+buffer.get(0)+"' Client received!");
buffer.flip();
client.write(buffer);
client.close();
}
}
}
}

public void doSomeWork() throws Exception {
// Simulates a processing time of about a 1/10 Second
if (workTime>0)
Thread.sleep(100);
workTime -= 100;
}

public static final void main(String args[]) throws Exception {
(new ServerNew()).start();
}
}

Der vollständige Quellcode des Servers mit den java.nio-Funktionen ist in Listing 3 dargestellt. Vom Aufbau und Ablauf gleicht er dem bereits oben diskutierten Server mit herkömmlicher I/O-API: Er besteht aus einem Thread, hat die Aufgabe ein empfangenes Byte an den Client zurückzuschicken und muss zwischenzeitlich eine Berechnung von zehn Sekunden durchführen.
Starten wir den Server und das obige Client-Programm, so werden erwartungsgemäß 100 Request/Response-Zyklen durchgeführt. Es ist festzustellen, dass für die Beantwortung aller Anfragen zwischen 27 und 31 Sekunden benötigt werden. Vergleicht man diesen Wert mit dem Ergebnis des alten Servers, so ergibt sich eine Einsparung von bis zu zehn Sekunden. Auch dieses Phänomen ist keine Zauberei, sondern schnell erklärt: Der Server wurde non-blocking programmiert. Während auf die Anfragen der Clients gewartet wird, legt sich der Server also nicht schlafen, sondern nutzt die Zeit, seine Berechnung durchzuführen. Dadurch hat er seine 10 Sekunden Arbeit schnell und unabhängig von den Client-Anfragen erledigt und kommt zu einer schnelleren Response-Zeit. Schauen wir uns die Implementierung des Servers detaillierter an:
Die Methoden main() und doSomeWork() sind identisch mit der alten Server-Implementierung und starten lediglich das Programm bzw. simulieren eine Bearbeitungszeit von 10 Sekunden. In unserer Server-Klasse sind zwei Member-Variablen für folgende Aufgaben definiert: Der readSelector lauscht an den SocketChannels der Clients und registriert eintreffende Daten. Der ByteBuffer wird genutzt zum Lesen und Schreiben aus bzw. in die Channels. Da unsere Protokoll-Nachrichten nur aus einem Byte bestehen, reicht uns die Größe von einem Byte aus.
Die Methode start() initialisiert unseren Server, indem sie den Serverport öffnet und in der Endlosschleife auf Client-Anfragen entgegennimmt. Im Unterschied zu dem alten Server fällt auf, dass hier neben Socket-Objekten weitere Channel-Objekte zum Einsatz kommen. Im Einzelnen: Zunächst erzeugen wir über die statische Methode Selector.open() den Selector, den wir später mit den einzelnen Client-Anfragen registrieren. Weiter wird ein ServerSocketChannel erzeugt, aus welchem wir dann den herkömmlichen ServerSocket kreieren und den Port festlegen. Wesentlich für das Beispielprogramm ist der Methodenaufruf channel.configureBlocking(false). Hierdurch wird veranlasst, dass der accept()-Aufruf in der folgenden Endlosschleife nicht blockiert. In der Schleife selber werden nun die Arbeitsmethoden doSomeWork() und handleClientRequest() aufgerufen. Sollte der Aufruf channel.accept() einen Wert ungleich null zurückliefern, so ist eine Client-Anfrage eingetroffen. In diesem Fall wird das zurückgegebene SocketChannel-Objekt ebenfalls auf non-blocking gestellt und mit unserem Selector registriert.
Für die Abarbeitung der Client-Anfragen ist die Methode handleClientRequest() zuständig. Bei jedem Aufruf wird zunächst selectNow() an unserem Selector-Objekt ausgeführt. Diese Methode selektiert alle registrierten Channels, für die das definierte I/O-Event – in unserem Falle Daten zum Lesen vorhanden -eingetroffen ist. Der Rückgabewert kennzeichnet die Anzahl der bereitstehenden Channels. Ist er größer als Null, lassen wir uns eine Menge von SelectionKey-Objekten zurückgeben, durch die wir iterieren. Wie oben bereits beschrieben repräsentiert jedes SelectionKey-Objekt eine Verknüpfung zwischen einem Channel und dem Selector. An dem SelectionKey prüfen wir zunächst durch den Methodenaufruf isReadable(), ob lesbare Daten vorhanden sind. Das ist bei uns immer der Fall, da wir die Registratur nur für dieses I/O-Event durchgeführt haben. Im nächsten Schritt lassen wir uns den SocketChannel eines Clients zurückgeben, leeren den ByteBuffer und lesen unser Byte aus dem Channel in den Buffer. Nach der Ausgabe einer Statusinformation auf die Konsole bemühen wir die Methode flip() auf den ByteBuffer. Hierdurch wird das Limit des Buffers auf die aktuelle Zeigerposition, bei uns also Position 1 – und der Zeiger zurück auf Position 0 gesetzt. So steht der ByteBuffer bereit, um über die write()-Methode des Channels an den Client zurückgesendet zu werden. Am Ende schließen wir den Channel, wodurch die dem Channel zugeordneten SelectionKeys ebenfalls geschlossen werden.

Fazit

Das neue I/O-API in Java 1.4 ermöglicht eine bisher nicht bekannte Flexibilität im Umgang mit Netz- und Dateizugriffen. Insbesondere beim richtigen Einsatz kann eine höhere Skalierbarkeit und eine stark verbesserte Performance von Java-Systemen erreicht werden.
Für Hersteller von Server-Systemen bzw. von Programmen mit zeitkritischen Netz- und Dateizugriffen sind die Neuerungen von hoher Bedeutung. Für diese Gruppe ist die Einarbeitung und die Umstellung auf das neue API wärmstens zu empfehlen, möchten sie den Anschluss an konkurrierende Software nicht verlieren.
Für andere Softwareprojekte, bei denen Netzfunktionen und Dateizugriffe keine performance-kritischen Bereiche darstellen, ist der Einsatz des neuen I/O-API nicht notwendig. Hier kann die Einarbeitung in das neue und teils durchaus lernaufwendige API gespart werden.
Einen kleinen Wehrmutstropfen stellt die nun erreichte Überfrachtung des Java I/O-APIs dar. Dadurch, dass Sun parallel zum alten I/O-API ein neues I/O-API mit neuen Konzept und teils überschneidenden Funktionen anbietet, leidet die Übersichtlichkeit. Wünschenswert wäre sicherlich ein durchgängiges I/O-Konzept gewesen, in dem die vollständige Funktionalität vereint wäre. Aber aufgrund der Kompatibilität zu alten Java-Versionen wäre dies sicherlich kein einfaches und sauber zu realisierendes Unterfangen gewesen.

Geschrieben von
Marc-Oliver Scheele
Kommentare
  1. Michael Behrendt2015-04-17 11:55:07

    Hallo,

    sehr schöner Artikel, der mir bestimmt bei der Einarbeitung in die java.nio API geholfen hätte, wenn das der Quelltext des "alten" Servers und des Clients vollständig angegeben wäre (nach Listing 1 kommt gleich 3??)!

    Oder übersehe ich da gerade etwas fürchterlich??

    1. Redaktion JAXenter2015-04-17 13:49:45

      Hallo Herr Behrendt,

      bedauerlicherweise sind im Zuge unseres Frühjahrsputzes einige der älteren Artikel in Mitleidenschaft gezogen worden. Bei über 9000 Artikeln werden die "Aufräumarbeiten" leider noch etwas Zeit in Anspruch nehmen, aber wir sind schon dran!

Schreibe einen Kommentar

Deine E-Mail-Adresse wird nicht veröffentlicht.