ByteBuffer - Wie funktionieren flip() und compact() - Feature-Bild

Java ByteBuffer: Wie funktionieren flip() und compact()?

Autor-Bild
von Sven Woltmann – 26. Februar 2020

In diesem Artikel zeige ich dir anhand eines Beispiels, wie der Java ByteBuffer funktioniert und was die Methoden flip() und compact() genau machen.

Folgende Fragen wird der Artikel beantworten:

  • Was ist ein ByteBuffer, und wofür benötigt man ihn?
  • Wie erzeugt man einen ByteBuffer?
  • Was bedeuten die Werte position, limit und capacity?
  • Wie schreibt man in den ByteBuffer, wie liest man daraus?
  • Was genau machen die Methoden flip() und compact()?

Los geht's!

Was ist ein ByteBuffer, und wofür benötigt man ihn?

Einen ByteBuffer benötigst du, um mit einem sogenannten "Channel" Daten in eine Datei, einen Socket oder eine andere I/O-Komponente zu schreiben bzw. daraus zu lesen.

(In diesem Artikel geht es vorranging um den ByteBuffer selbst. Wie du mit dem ByteBuffer und einem FileChannel Dateien schreibst und liest, erfährst du im FileChannel-Artikel des Dateien-Tutorials.)

Ein ByteBuffer ist ein Wrapper um ein Byte-Array und bietet Methoden zum komfortablen Schreiben in und Lesen aus dem Byte-Array. Der ByteBuffer speichert intern die Schreib-/Lese-Position und ein sogenanntes "Limit".

Was das genau bedeutet, erfährst du im folgenden Beispiel – Schritt-für-Schritt.

Den im Zuge dieses Artikels geschriebenen Programmcode findest du in diesem GitHub-Repository.

Einen ByteBuffer erzeugen

Als erstes muss ein ByteBuffer mit einer vorgegebenen Größe (englisch: "capacity") angelegt werden. Hierfür gibt es zwei Methoden:

  • ByteBuffer.allocate(int capacity)
  • ByteBuffer.allocateDirect(int capacity)

Mit dem Parameter capacity wird die Größe des Buffers in Byte angegeben.

Die Methode allocate() legt den Buffer im Java Heap Memory an, wo er nach der Verwendung durch den Garbage Collector wieder entfernt wird.

allocateDirect() hingegen legt den Buffer im nativen Speicher an, also außerhalb des Heaps. Das hat den Vorteil, dass Schreib- und Leseoperationen schneller ausgeführt werden, da die entsprechenden Betriebssystem-Operationen direkt auf diesen Speicherbereich zugreifen können und die Daten nicht zunächst zwischen dem Java-Heap und dem Betriebssystem ausgetauscht werden müssen. Der Nachteil dieser Methode sind höhere Allokations- und Deallokationskosten.

Wir legen wie folgt einen ByteBuffer mit einer Größe von 1.000 Bytes an:

var buffer = ByteBuffer.allocate(1000);
Code-Sprache: Java (java)

Danach schauen wir uns die Kennzahlen des Buffers an – nämlich position, limit und capacity.

Da wir diese Kennzahlen im Verlauf des Beispiels wiederholt ausgeben werden, legen wir dafür eine printMetrics-Methode an:

private static void printMetrics(ByteBuffer buffer) { System.out.printf("position = %4d, limit = %4d, capacity = %4d%n", buffer.position(), buffer.limit(), buffer.capacity()); }
Code-Sprache: Java (java)

Nach dem Anlegen des ByteBuffers sehen wir folgende Ausgabe:

position = 0, limit = 1000, capacity = 1000
Code-Sprache: Klartext (plaintext)

Hier eine grafische Darstellung, damit du dir den Buffer besser vorstellen kannst. Der gelbe Bereich ist leer und kann im folgenden gefüllt werden.

ByteBuffer mit position = 0, limit = 1000,  capacity = 1000

ByteBuffer-Position, -Limit und -Capacity

Die ausgegebenen Werte bedeuten im Einzelnen:

  • position ist die Schreib-/Leseposition. Diese ist bei einem neuen Buffer immer 0.
  • limit hat zwei Bedeutungen: Wenn wir in den Buffer schreiben, kennzeichnet limit die Position, bis zu der wir schreiben können. Wenn wir aus dem Buffer lesen, zeigt limit, bis zu welcher Position der Buffer Daten enthält. Initial ist ein ByteBuffer immer im Schreibmodus und limit gleicht capacity – den leeren Buffer können wir bis zum Ende beschreiben.
  • capacity bezeichnet die Größe des Buffers. Diese entspricht den 1.000 Bytes, die wir bei der allocate()-Methode angegebenen haben. Sie wird sich während der Lebenszeit des Buffers nicht mehr ändern.

Der ByteBuffer-Schreib-Lese-Zyklus

Ein vollständiger Schreib-Lese-Zyklus besteht aus den Schritten put(), flip(), get() und compact(). Diese werden wir uns in den folgenden Abschnitten anschauen.

Schreiben in den ByteBuffer mit put()

Zum Schreiben in den ByteBuffer gibt es diverse put()-Methoden, um einzelne Bytes, ein Byte-Array oder auch andere primitive Datentypen (wie char, double, float, int, long, short) in den Buffer zu schreiben.

In unserem Beispiel schreiben wir 100 mal den Wert 1 in den Buffer und schauen uns danach erneut die Buffer-Kennzahlen an:

for (int i = 0; i < 100; i++) { buffer.put((byte) 1); } printMetrics(buffer);
Code-Sprache: Java (java)

Wenn wir das Programm laufen lassen, sehen wir folgende Ausgabe:

position = 100, limit = 1000, capacity = 1000
Code-Sprache: Klartext (plaintext)

Die Position hat sich also um 100 Bytes nach rechts verschoben; der Buffer sieht nun wie folgt aus:

ByteBuffer mit position = 100, limit = 1000,  capacity = 1000

Als nächstes schreiben wir 200 mal eine Zwei in den Buffer. Dieses Mal verwenden wir dazu eine andere Methode: Wir füllen zunächst ein Byte-Array, kopieren dieses in den Buffer, und geben wieder die Metriken aus:

byte[] twos = new byte[200]; Arrays.fill(twos, (byte) 2); buffer.put(twos); printMetrics(buffer);
Code-Sprache: Java (java)

Wir sehen nun:

position = 300, limit = 1000, capacity = 1000
Code-Sprache: Klartext (plaintext)

Die Position hat sich um weitere 200 Bytes nach rechts verschoben; der Buffer sieht wie folgt aus:

ByteBuffer mit position = 300, limit = 1000,  capacity = 1000

Umschalten in den Lesemodus mit Buffer.flip()

Zum Lesen aus dem Buffer gibt es entsprechende get()-Methoden. Diese werden bspw. beim Schreiben in einen Channel durch Channel.write(buffer) aufgerufen.

Da position nicht nur die Schreib-, sondern auch die Leseposition anzeigt, müssen wir position zurück auf 0 setzen.

Gleichzeitig setzen wir limit auf 300, um zu signalisieren, dass aus dem Buffer maximal 300 Bytes gelesen werden können.

Im Programmcode machen wir das wie folgt:

buffer.limit(buffer.position()); buffer.position(0);
Code-Sprache: Java (java)

Da diese zwei Zeilen bei jedem Umschalten vom Schreib- in den Lesemodus benötigt werden, gibt es eine ByteBuffer-Methode, die genau dasselbe für uns tut:

buffer.flip();
Code-Sprache: Java (java)

Ein Aufruf von printMetrics() zeigt uns nun folgende Werte:

position = 0, limit = 300, capacity = 1000
Code-Sprache: Klartext (plaintext)

Der Positions-Zeiger ist also an den Anfang des Buffers zurückgesprungen, und limit zeigt auf das Ende des gefüllten Bereichs:

ByteBuffer mit position = 0, limit = 300,  capacity = 1000

Damit ist der Buffer bereit, um gelesen zu werden.

Lesen aus dem ByteBuffer mit get()

Nehmen wir an, der Channel, in den wir schreiben wollen, kann momentan nur 200 der 300 Bytes aufnehmen. Das können wir simulieren wir, in dem wir der ByteBuffer.get()-Methode ein 200 Byte großes Byte-Array übergeben, in das der Buffer seine Daten schreiben soll:

buffer.get(new byte[200]);
Code-Sprache: Java (java)

printMetrics() zeigt uns nun folgendes an:

position = 200, limit = 300, capacity = 1000
Code-Sprache: Klartext (plaintext)

Die Leseposition ist um 200 Bytes nach rechts gerückt – also ans Ende der gelesenen Daten, bzw. an den Anfang der noch nicht gelesenen Daten:

ByteBuffer mit position = 200, limit = 300,  capacity = 1000

Umschalten in den Schreibmodus – wie man es nicht macht

Um jetzt wieder in den Buffer zu schreiben, könnte man folgenden Fehler begehen: Man setzt position auf das Ende der Daten, also 300, und limit wieder auf 1.000. Damit sind wir bei exakt dem Zustand, in dem wir nach dem Schreiben der Einsen und Zweien waren:

ByteBuffer mit position = 300, limit = 1000,  capacity = 1000

Nehmen wir an, wir würden jetzt 300 weitere Bytes in den Buffer schreiben. Der Buffer sähe danach wie folgt aus:

ByteBuffer mit position = 600, limit = 1000,  capacity = 1000

Wenn wir nun mit flip() zurück in den Lesemodus schalten würden, dann wäre position wieder auf 0:

ByteBuffer mit position = 0, limit = 600,  capacity = 1000

Jetzt würden wir allerdings die ersten, bereits gelesenen, 200 Bytes noch einmal lesen.

Diese Vorgehensweise ist also falsch. Wie man es richtig macht, erklärt der folgende Abschnitt.

Umschalten in den Schreibmodus mit Buffer.compact()

Stattdessen müssen wir beim Umschalten in den Schreibmodus wie folgt vorgehen:

  • Wir berechnen die Anzahl der verbleibenden Bytes: remaining = limit - position, im Beispiel ergibt das 100.
  • Wir schieben die verbleibenden Bytes an den Anfang des Buffers.
  • Wir setzen die Schreibposition ans Ende der nach links verschobenen Bytes, im Beispiel also auf 100.
  • Wir setzen limit auf das Ende des Buffers.

Auch hierfür stellt ByteBuffer eine Methode zur Verfügung:

buffer.compact();
Code-Sprache: Java (java)

Nach dem Aufruf von compact() zeigt uns printMetrics() folgendes an:

position = 100, limit = 1000, capacity = 1000
Code-Sprache: Klartext (plaintext)

In der Grafik sieht der compact()-Vorgang wie folgt aus:

ByteBuffer mit position = 100, limit = 1000,  capacity = 1000

Der nächste Zyklus

Jetzt können wir die nächsten 300 Bytes in den Buffer schreiben:

byte[] threes = new byte[300]; Arrays.fill(threes, (byte) 3); buffer.put(threes);
Code-Sprache: Java (java)

printMetrics() zeigt uns jetzt folgende Werte an:

position = 400, limit = 1000, capacity = 1000
Code-Sprache: Klartext (plaintext)

Nach dem Schreiben der Dreien ist position um 300 Bytes nach rechts gerückt:

ByteBuffer mit position =  400, limit = 1000, capacity = 1000

Jetzt können wir problemlos mit flip() wieder in den Lesemodus umschalten:

buffer.flip();
Code-Sprache: Java (java)

Ein letzter Aufruf von printMetrics() gibt folgende Werte aus:

position = 0, limit = 400, capacity = 1000
Code-Sprache: Klartext (plaintext)

Die Leseposition steht am Anfang des Buffers, dort wo die verschobenen 100 Zweien liegen. Wir können nun also an genau der Position weiterlesen, an der wir vorhin aufgehört haben.

ByteBuffer mit position =  0, limit = 400, capacity = 1000

Wenn wir es richtig machen, lesen wir also keine Daten doppelt :)

Zusammenfassung

Dieser Artikel hat die Funktionsweise des Java ByteBuffers und dessen Methoden flip() und compact() anhand eines Beispiels erklärt.

Wenn dir der Artikel geholfen hat den ByteBuffer besser zu verstehen, dann teile ihn gerne über einen der Share-Buttons unten, und hinterlasse mir einen Kommentar.

Möchtest du informiert werden, wenn neue Artikel auf HappyCoders.eu veröffentlicht werden? Dann klicke hier, um dich für den HappyCoders.eu-Newsletter anzumelden.