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

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

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

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

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 meinem GitLab-Repository.

Einen ByteBuffer erzeugen

Als erstes muss ein ByteBuffer mit einer festgelegten 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 können, 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);

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

System.out.printf("position = %4d, limit = %4d, capacity = %4d%n",
    buffer.position(), buffer.limit(), buffer.capacity());

(Da wir diese Kennzahlen im Verlauf des Beispiels wiederholt ausgeben werden, extrahieren wir das System.out.println()-Kommando direkt in eine Methode printMetrics(buffer).)

Wir sehen folgende Ausgabe:

position = 0, limit = 1000, capacity = 1000

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 der allocate()-Methode übergebenen haben. Sie wird sich während der Lebenszeit des Buffers nicht mehr ändern.

Der ByteBuffer-Schreib-Lese-Zyklus

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. Zunächst 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);

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

position = 100, limit = 1000, capacity = 1000

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

Wir sehen nun:

position = 300, limit = 1000, capacity = 1000

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

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

buffer.flip();

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

position = 0, limit = 300, capacity = 1000

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

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

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

printMetrics() zeigt uns nun folgendes an:

position = 200, limit = 300, capacity = 1000

Die Leseposition ist um 200 Bytes nach rechts gerückt – also ans Ende der gelesenen Daten, bzw. an den Anfang der noch zu lesenden 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 1000. 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();

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

position = 100, limit = 1000, capacity = 1000

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

printMetrics() zeigt uns jetzt folgende Werte an:

position = 400, limit = 1000, capacity = 1000

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

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

position = 0, limit = 400, capacity = 1000

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

Zusammenfassung

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

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

Gerne kannst du dich auch in meinen E-Mail-Verteiler eintragen und wirst informiert, sobald ein neuer Artikel veröffentlicht wird.

Die folgenden Artikel könnten dir auch gefallen
Schreibe einen Kommentar

Deine E-Mail-Adresse wird nicht veröffentlicht. Pflichtfelder sind markiert.

{"email":"Email address invalid","url":"Website address invalid","required":"Required field missing"}