Panoramica di Java NIO
Introduzione a Java NIO e NIO.2 — canali, buffer, selettori e il pacchetto java.nio.file.
I quindici capitoli precedenti riguardavano java.io — stream, Reader/Writer, File, Serializable. Quella API era l'I/O originale di Java ed è ancora ampiamente utilizzata. NIO è la famiglia di API aggiunta successivamente da Java per coprire ciò che java.io non poteva. Si compone di due parti che condividono un prefisso di pacchetto e poco altro:
- NIO (Java 1.4, 2002) —
java.nio.*— canali, buffer, selettori. Una forma diversa per l'I/O: basata su byte-buffer, facoltativamente non bloccante, progettata per server ad alto throughput. - NIO.2 (Java 7, 2011) —
java.nio.file.*— le classiPath,Files,FileSystemeWatchService. Un sostituto più comodo dijava.io.Filee una sede per funzionalità del filesystem chejava.ionon ha mai avuto (link simbolici, attributi estesi, I/O su file asincrono, monitoraggio delle directory).
Stai utilizzando parti di NIO.2 fin dall'inizio di questa sezione: Path, Files.newBufferedReader, Files.newInputStream sono tutti java.nio.file. Questo capitolo fa un passo indietro e mostra dove si inseriscono questi elementi e a cosa serve il resto del pacchetto.
Stream e canale: due forme diverse
InputStream.read() restituisce un byte. OutputStream.write(int) scrive un byte. Il modello mentale è una pipe byte per byte. I decoratori con buffer la rendono veloce, ma l'astrazione è sequenziale e monodirezionale.
Un canale (java.nio.channels.Channel) è bidirezionale, orientato ai byte-buffer e supporta operazioni che InputStream non può esprimere:
- Lettura e scrittura attraverso un
ByteBuffer— non unbyte[]. - Memory-map di una regione di file in RAM e lettura/scrittura come buffer.
- Scatter di una lettura in più buffer (header → uno, payload → un altro).
- Gather di una scrittura da più buffer (un singolo
write()produce un output contiguo). - Contrassegnare un canale come non bloccante e lasciare che un
Selectormultiplexing migliaia di essi su un singolo thread.
Il compromesso è la verbosità. Il codice con canali legge e scrive attraverso un ByteBuffer con chiamate esplicite a flip() e position(); java.io nasconde tutto ciò dietro read(byte[]). Per la lettura tipica di file, preferisci le API java.io/Files. Passa ai canali quando hai bisogno di una delle funzionalità esclusive dei canali.
// channel-shaped read into a 1 KB buffer
try (FileChannel ch = FileChannel.open(path, StandardOpenOption.READ)) {
ByteBuffer buf = ByteBuffer.allocate(1024);
int n = ch.read(buf); // fills the buffer; updates position
buf.flip(); // switch to "read what was just written"
while (buf.hasRemaining()) {
process(buf.get());
}
}Il passaggio flip() è il momento in cui le persone imparano che ByteBuffer ha la propria piccola macchina a stati.
ByteBuffer: position, limit, capacity
Un ByteBuffer è un byte[] di dimensione fissa (o un blocco di memoria off-heap) più tre indici:
position— il prossimo byte da leggere o scrivere.limit— l'indice oltre l'ultimo byte che puoi toccare.capacity— la dimensione fissa del buffer; non può cambiare.
0 ─────── position ─────── limit ─────── capacity
(consumed) (active region) (untouchable / empty)Il buffer si trova convenzionalmente in una di due modalità:
- Modalità scrittura (predefinita): inserisci byte con
put(byte).positionavanza;limit == capacity. - Modalità lettura: estrai byte con
get().positionavanza;limitè dove hai smesso di scrivere.
flip() passa dalla scrittura alla lettura: imposta limit = position (segna dove terminano i dati) e azzera position = 0 (inizia a leggere dall'inizio). clear() torna alla modalità scrittura (position = 0, limit = capacity). Gli errori qui sono la fonte più comune della frustrazione "ho letto zero byte; perché?".
I buffer off-heap (ByteBuffer.allocateDirect(n)) bypassano l'heap della JVM e consentono al sistema operativo di leggerli/scriverli direttamente senza una copia extra. Sono più lenti da allocare, più veloci per le operazioni di I/O, e la scelta giusta solo per il codice di I/O nel percorso critico.
Selettori: un thread, molti canali
Prima dei thread virtuali (Java 21), gestire migliaia di connessioni di rete concorrenti in Java significava o migliaia di thread OS (uno per connessione — costoso) oppure un singolo thread che faceva multiplexing con un Selector:
Selector sel = Selector.open();
serverChannel.register(sel, SelectionKey.OP_ACCEPT);
while (true) {
sel.select(); // blocks until any channel is ready
for (SelectionKey k : sel.selectedKeys()) {
if (k.isAcceptable()) accept(k);
if (k.isReadable()) read(k);
}
}Il sistema operativo notifica la JVM quando qualsiasi canale registrato può fare progressi; la JVM ti fornisce il set pronto; esegui una lettura o scrittura non bloccante e torni a select(). Il codice framework sotto Netty, gRPC e Spring WebFlux ha questa forma.
Con i thread virtuali (Thread.startVirtualThread(...)), il più semplice pattern "un thread per richiesta" scala agli stessi conteggi di connessione senza la coreografia del Selector — i thread virtuali si parcheggiano su I/O bloccante essenzialmente senza costi. Per il codice applicativo nuovo su Java 21+, il ciclo del selettore è sempre più una preoccupazione delle librerie; di solito non lo scrivi a mano. Per il codice di libreria e le JVM pre-Loom, è il pattern standard.
java.nio.file: la moderna API per i file
Questa è la metà di NIO che utilizzerai nel codice di tutti i giorni. Sostituisce java.io.File e la maggior parte delle parti relative ai file di java.io:
java.io | java.nio.file | Motivo della sostituzione |
|---|---|---|
File | Path | Immutabile, agnostico rispetto all'OS, nessun metodo I/O integrato |
File.list() | Files.list(Path), Files.walk(Path) | Stream<Path>; chiudibile; rispetta i link simbolici |
new FileInputStream(...) | Files.newInputStream(path) | Varianti consapevoli del charset per il testo; un'unica API di apertura coerente |
file.delete() che restituisce false in caso di fallimento | Files.delete(path) che lancia IOException | I fallimenti sono visibili, non silenziosi |
| nessun equivalente | Files.walkFileTree, WatchService, API per link simbolici, viste degli attributi di file | Funzionalità che java.io non ha mai avuto |
I prossimi due capitoli coprono Path e Files in dettaglio. La regola empirica: per il lavoro con file in Java dal 2024 in poi, usa java.nio.file. java.io.File è ancora presente perché il vecchio codice lo utilizza, ma il nuovo codice dovrebbe fare riferimento a Path per impostazione predefinita.
Un esempio pratico: round-trip di un file tramite canale e buffer
Il programma seguente copia un piccolo file di testo nel modo canale-e-buffer per rendere concreti position/limit/flip. Apre il sorgente come FileChannel, legge in un ByteBuffer, fa il flip, scrive su un FileChannel di destinazione e stampa lo stato del buffer a ogni passaggio così puoi vedere come si spostano gli indici.
Cosa ricavare dall'esecuzione:
- Il ciclo ha stampato lo stato del buffer a ogni passaggio. Dopo un
read(),positionera il numero di byte letti elimitera ancoracapacity— questa è la "modalità scrittura": spazio rimasto alla fine. Dopoflip(),position = 0elimit = il numero appena letto— questa è la "modalità lettura": i byte si trovano tra 0 elimit. I due indici codificano "dove vivono i dati" senza copiarli. - Il buffer era di 16 byte; il file era 44. Il ciclo è andato in tre iterazioni: 16, 16, 12. Una volta che il buffer era vuoto (dopo che
writelo aveva svuotato),clear()lo ha reimpostato in "modalità scrittura" così il prossimoread()poteva riempirlo di nuovo. Questo è il pattern del canale in miniatura: riempire, flip, svuotare, clear, ripetere. transferToha eseguito la stessa copia in una sola riga senza alcunByteBuffercoinvolto. Su Linux, questo corrisponde a una singola syscallsendfile()— i byte viaggiano da kernel a kernel senza attraversare la JVM. Quando stai spostando dati tra due canali e non hai bisogno di esaminarli, questo è lo strumento giusto.- Nota che il file sorgente è stato creato con
Files.writeStringe la destinazione riletta conFiles.readString— entrambi sono one-liner dijava.nio.fileche nascondono completamente canali e buffer. Il ciclo dettagliato con canali nel mezzo è quello che scriveresti solo quando hai bisogno di accesso diretto al buffer (parsing binario personalizzato, memory mapping, scatter/gather). Per "copiare un file",transferTooFiles.copyè più breve e almeno altrettanto veloce. - Il costruttore
FileChannel.open(path, OPTION)è il parallelo diFiles.newInputStream(path). L'enumStandardOpenOption(READ, WRITE, CREATE, APPEND, TRUNCATE_EXISTING, ...) è ciò che controlla il comportamento di apertura — c'è un solo posto dove guardare. Quell'enum di opzioni di apertura ricorre nel capitolo successivo.
Cosa viene dopo
Questo capitolo ha nominato i pezzi — canali, buffer, selettori, java.nio.file. Il prossimo capitolo, La classe Java Path, approfondisce il più amichevole di questi pezzi — Path — e i metodi (resolve, relativize, normalize) che utilizzerai ogni volta che lavori con un percorso del filesystem.