CodeGym /Corsi /JAVA 25 SELF /NIO Channels e ByteBuffer

NIO Channels e ByteBuffer

JAVA 25 SELF
Livello 41, Lezione 1
Disponibile

1. Introduzione ai NIO Channels

Nel classico Java IO (java.io) tutto è organizzato secondo il principio «un thread — un file o una risorsa». Non appena inizia la lettura o la scrittura, il thread si blocca e attende il completamento dell’operazione. Per i casi semplici è comodo, ma nei sistemi ad alto carico questo approccio diventa un collo di bottiglia: se le connessioni sono migliaia, migliaia di thread restano in attesa.

In NIO (New IO) l’approccio è diverso. Qui l’I/O può essere non bloccante e il thread non è costretto a rimanere inattivo. Mentre alcuni dati sono ancora in transito, può passare a un altro compito. Questo permette di gestire un numero enorme di connessioni con pochi thread.

La differenza si nota anche nei dettagli. Nel «vecchio» IO il lavoro ruota attorno agli stream che leggono e scrivono byte o caratteri, ma si bloccano sempre per la durata delle operazioni. In NIO i concetti chiave diventano i canali (Channels) e i buffer (Buffers). Consentono di implementare I/O non bloccante (importante per i server) e di applicare lo zero-copy, quando i dati vengono trasferiti direttamente evitando copie superflue nei buffer della JVM.

Confronto: stream (Streams) vs canali (Channels)

Stream (InputStream/OutputStream):

  • Leggono/scrivono byte singoli o array di byte.
  • Nessun controllo diretto sulla posizione nel file.
  • Non sono efficienti con file molto grandi.

Canali (Channel):

  • Leggono/scrivono dati tramite buffer (Buffer).
  • È possibile gestire la posizione (incluso l’accesso casuale).
  • Supportano l’asincronia e la modalità non bloccante.
  • Consentono l’uso di zero-copy per copie ad altissime prestazioni.

2. FileChannel e SeekableByteChannel

Lettura e scrittura dei dati usando i buffer

FileChannel è il canale principale per lavorare con i file. Si può ottenere da FileInputStream, FileOutputStream o tramite NIO.2 — Files.newByteChannel (restituisce SeekableByteChannel).

Esempio: lettura di un file tramite FileChannel e ByteBuffer

import java.io.RandomAccessFile;
import java.nio.ByteBuffer;
import java.nio.channels.FileChannel;

public class FileChannelReadExample {
    public static void main(String[] args) throws Exception {
        try (RandomAccessFile file = new RandomAccessFile("data.txt", "r");
             FileChannel channel = file.getChannel()) {

            ByteBuffer buffer = ByteBuffer.allocate(1024); // buffer da 1 KB

            int bytesRead = channel.read(buffer); // leggiamo nel buffer
            while (bytesRead != -1) {
                buffer.flip(); // passiamo il buffer in modalità lettura
                while (buffer.hasRemaining()) {
                    System.out.print((char) buffer.get());
                }
                buffer.clear(); // puliamo il buffer per la lettura successiva
                bytesRead = channel.read(buffer);
            }
        }
    }
}

Scrittura su file:

try (RandomAccessFile file = new RandomAccessFile("output.txt", "rw");
     FileChannel channel = file.getChannel()) {

    ByteBuffer buffer = ByteBuffer.wrap("Hello, NIO!\n".getBytes());
    channel.write(buffer);
}

NIO.2: apertura del canale tramite Files.newByteChannel

import java.nio.file.*;
import java.nio.channels.SeekableByteChannel;
import static java.nio.file.StandardOpenOption.*;

Path path = Paths.get("data.txt");
try (SeekableByteChannel ch = Files.newByteChannel(path, READ)) {
    ByteBuffer buf = ByteBuffer.allocate(256);
    ch.read(buf);
}

Posizionamento (position()) e ridimensionamento (truncate())

  • position() — consente di ottenere o impostare la posizione corrente nel file (l’equivalente di un “cursore”).
  • truncate(long size) — tronca il file alla dimensione indicata.
channel.position(100);   // ci spostiamo al byte 100
channel.truncate(1024);  // tronchiamo il file a 1 KB

Accesso diretto e posizionale ai file

  • Accesso diretto: è possibile leggere/scrivere in qualsiasi punto del file, non solo in modo sequenziale.
  • Accesso posizionale: si possono leggere/scrivere dati in una posizione specifica senza modificare la posizione corrente del canale.
ByteBuffer buffer = ByteBuffer.allocate(4);
channel.read(buffer, 128);   // leggiamo 4 byte dalla posizione 128 senza modificare channel.position()

3. ByteBuffer: come funziona

Parametri principali: capacity, limit, position, mark

  • capacity — dimensione massima del buffer (impostata alla creazione).
  • limit — il limite fino a cui si può leggere/scrivere (per impostazione predefinita uguale a capacity).
  • position — posizione corrente (dove scriviamo/da dove leggiamo).
  • mark — un “segnalibro” che si può impostare e al quale si può tornare.

Ciclo di vita del buffer:

  1. Scriviamo dati nel buffer (ad esempio leggendo dal canale read()).
  2. flip() — passiamo il buffer in modalità lettura (position = 0, limit = valore corrente di position).
  3. Leggiamo i dati dal buffer (get()).
  4. clear() — azzeriamo il buffer per la prossima scrittura (position = 0, limit = capacity).

Esempio:

ByteBuffer buffer = ByteBuffer.allocate(8);
buffer.put((byte) 42);
buffer.flip();                // ora si può leggere
byte value = buffer.get();    // 42
buffer.clear();               // pronto per una nuova scrittura

Creazione dei buffer: allocate() vs allocateDirect()

ByteBuffer ha due modi principali per creare un buffer, e la differenza tra i due è evidente in pratica. Il metodo allocate() alloca il buffer nell’heap della JVM: si crea rapidamente e va bene per la maggior parte dei casi, ma con I/O nativo possono verificarsi copie aggiuntive tra l’heap e la memoria del sistema operativo.

Il metodo allocateDirect() alloca memoria al di fuori dell’heap della JVM (nella “native memory”). Un buffer di questo tipo è più costoso da creare e più complesso da gestire, ma in lettura/scrittura di file grandi o nelle operazioni di rete è spesso più veloce grazie all’assenza di copie superflue.

L’idea è semplice: se la prestazione su grandi volumi è fondamentale — usate i buffer “direct”. Per operazioni piccole e frequenti, i costi di creazione possono superare i benefici.

ByteBuffer directBuffer = ByteBuffer.allocateDirect(4096);

4. Operazioni ad alte prestazioni: transferTo() e transferFrom()

I metodi transferTo() e transferFrom()

La classe FileChannel offre due metodi che permettono il cosiddetto “zero-copy” — transferTo() e transferFrom(). L’idea è che i dati possano essere spostati direttamente tra canali di file o, per esempio, tra file e rete. La JVM partecipa pochissimo: l’operazione è eseguita dal sistema operativo e i buffer lato Java non vengono toccati.

Il risultato è che la copia di file grandi risulta sensibilmente più veloce: meno copie, meno passaggi tra user space e kernel space, minore carico sulla CPU.

Esempio: copia di un file tramite zero-copy

import java.nio.channels.FileChannel;
import java.nio.file.*;

public class ZeroCopyExample {
    public static void main(String[] args) throws Exception {
        try (FileChannel src = FileChannel.open(Paths.get("input.bin"), StandardOpenOption.READ);
             FileChannel dst = FileChannel.open(Paths.get("output.bin"), StandardOpenOption.CREATE, StandardOpenOption.WRITE)) {

            long size = src.size();
            long transferred = src.transferTo(0, size, dst);
            System.out.println("Byte copiati: " + transferred);
        }
    }
}

Quando lo zero-copy funziona davvero?

  • Quando si copia tra file sullo stesso disco.
  • Quando si inviano file tramite la rete (ad esempio con SocketChannel).
  • Quando il sistema operativo supporta lo zero-copy (Linux, macOS, Windows — lo supportano).

Vantaggi:

  • Minime copie: i dati non passano attraverso i buffer della JVM.
  • Alta velocità: meno passaggi e minore carico della CPU.
  • Meno memoria: non servono grandi buffer lato utente.

Esempio: copia di un file «in una riga»

Files.copy(Paths.get("input.bin"), Paths.get("output.bin"), StandardCopyOption.REPLACE_EXISTING);
// Internamente può usare lo zero-copy, se possibile

5. Errori tipici

Errore n. 1: dimenticare flip() prima di leggere dal buffer. Dopo aver scritto nel buffer chiamate sempre flip(), altrimenti la lettura non funzionerà come previsto: position/limit resteranno in “modalità scrittura”.

Errore n. 2: usare allocateDirect() per operazioni piccole. I buffer direct sono ottimi per grandi volumi, ma per richieste piccole la loro creazione è inutilmente costosa. In generale preferite allocate().

Errore n. 3: non chiudere il canale. Usate sempre try-with-resources per canali e stream, per evitare perdite di descrittori.

Errore n. 4: confondere position/limit/capacity. Prima di leggere/scrivere verificate in quale modalità si trova il buffer: dopo aver scritto serve flip(), dopo la lettura, per una nuova scrittura, usate clear() o compact().

Errore n. 5: aspettarsi che lo zero-copy “funzioni sempre”. Su alcune configurazioni (dispositivi diversi/file system differenti/flag speciali) lo zero-copy può non essere disponibile — in tal caso avverrà una copia tradizionale e le prestazioni saranno diverse.

1
Compito
JAVA 25 SELF, livello 41, lezione 1
Bloccato
Chirurgia dei dati: Scolpitura del rotolo binario ✂️
Chirurgia dei dati: Scolpitura del rotolo binario ✂️
1
Compito
JAVA 25 SELF, livello 41, lezione 1
Bloccato
Corriere digitale: Consegna istantanea di copie 📦
Corriere digitale: Consegna istantanea di copie 📦
Commenti
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION