Java Buffered Streams
Velocizza l'I/O in Java con gli stream bufferizzati: BufferedReader, BufferedWriter, BufferedInputStream, BufferedOutputStream.
I capitoli sugli stream di byte e di caratteri hanno descritto onestamente le API grezze: ogni chiamata a FileInputStream.read() o FileReader.read() è una syscall. Una syscall richiede circa un microsecondo — veloce in isolamento, catastrofica in un ciclo stretto. Leggere un file da 1 MB un byte alla volta significa un milione di syscall; lo stesso file con un buffer da 8 KB ne richiede solo 128. La differenza in termini di tempo reale è di due o tre ordini di grandezza.
I decoratori Buffered* si interpongono tra il tuo codice e lo stream grezzo. Mantengono un byte[] (o char[]) in memoria e servono le chiamate read() da lì, andando al sistema operativo solo quando il buffer è vuoto. Lato scrittura, accumulano piccole scritture nel buffer e le inviano al SO solo quando il buffer è pieno o chiami flush/close. Stessa API, costo completamente diverso.
Le quattro classi bufferizzate
| Classe | Racchiude |
|---|---|
BufferedInputStream | Un InputStream. Aggiunge un buffer interno byte[]. |
BufferedOutputStream | Un OutputStream. Aggiunge un buffer interno byte[]. |
BufferedReader | Un Reader. Aggiunge un buffer interno char[] e il famoso metodo readLine(). |
BufferedWriter | Un Writer. Aggiunge un buffer interno char[] e un metodo newLine(). |
Tutte e quattro racchiudono qualsiasi stream del tipo corrispondente — file, socket, pipe, in memoria — non solo stream di file:
BufferedInputStream in = new BufferedInputStream(new FileInputStream(path.toFile()));
BufferedOutputStream out = new BufferedOutputStream(new FileOutputStream(path.toFile()));
BufferedReader r = new BufferedReader(new InputStreamReader(in, StandardCharsets.UTF_8));
BufferedWriter w = new BufferedWriter(new OutputStreamWriter(out, StandardCharsets.UTF_8));La dimensione predefinita del buffer è 8192 byte/char — scelta per corrispondere alle dimensioni comuni delle pagine del SO. Puoi passare una dimensione diversa al secondo costruttore, ma quella predefinita va benissimo in quasi tutti i casi. Buffer più grandi non aumentano la velocità in modo lineare; consumano semplicemente più memoria.
La moderna API fornisce questi decoratori già assemblati:
BufferedReader r = Files.newBufferedReader(path); // UTF-8 by default
BufferedWriter w = Files.newBufferedWriter(path, StandardCharsets.UTF_8);
InputStream in = new BufferedInputStream(Files.newInputStream(path));
OutputStream out = new BufferedOutputStream(Files.newOutputStream(path));Files.newBufferedReader / Files.newBufferedWriter racchiudono già la classe bridge con il charset corretto e un BufferedReader/BufferedWriter. Per il testo, questo sostituisce con una sola riga lo stack manuale profondo tre livelli.
BufferedReader.readLine()
Il motivo per cui BufferedReader è la classe più utilizzata in java.io:
String readLine() throws IOException; // a line, terminator stripped, or null at end
Stream<String> lines(); // Java 8+: line streamreadLine riconosce \n, \r e \r\n come terminatori di riga e restituisce la riga senza il terminatore. Restituisce null (non una stringa vuota, non -1) alla fine dello stream — l'idioma standard per la lettura riga per riga:
try (BufferedReader r = Files.newBufferedReader(path)) {
String line;
while ((line = r.readLine()) != null) {
process(line);
}
}r.lines() restituisce un Stream<String> per la forma con pipeline funzionale. Lo stream possiede il Reader aperto, quindi il try-with-resources attorno al reader gestisce comunque la chiusura — lines() stesso non ha bisogno di una propria chiusura.
Due cose da sapere su readLine(). Prima: alloca una String per ogni riga. Per cicli intensivi di elaborazione di log in cui l'allocazione è critica, la soluzione migliore è il read(char[]) di livello inferiore. Seconda: una riga vuota è "" (una stringa vuota), non null — il file termina solo quando readLine() restituisce null.
BufferedWriter.newLine()
Il corrispondente comodo lato scrittura:
void newLine() throws IOException; // platform line separator: \n on Unix, \r\n on WindowsnewLine() scrive qualunque separatore di riga la JVM consideri corretto per la piattaforma corrente. Questo è un vantaggio se stai producendo file per occhi umani sulla macchina locale; è un bug se stai producendo file di dati, file di log, o qualsiasi cosa destinata a un'altra macchina. Internet funziona con \n. Scrivi sempre \n esplicitamente quando l'output deve essere portabile:
w.write("line one\n"); // portable
w.newLine(); // platform-dependent: \n on Unix, \r\n on WindowsLo stesso consiglio vale per PrintWriter.println e il formato %n — dipendono dalla piattaforma. Usali solo quando l'output è per il consumo locale.
La trappola del "buffer finale mai svuotato"
Questo è il bug che ogni codebase Java incontra almeno una volta:
// WRONG
BufferedWriter w = Files.newBufferedWriter(path);
w.write("hello");
return; // 'hello' is sitting in the buffer; nothing on diskUn BufferedWriter non invia byte al SO finché il buffer non è pieno o close() non viene eseguito. Saltare la chiusura significa perdere la coda — Files.size(path) è 0 e non si capisce perché. La soluzione è usare try-with-resources ogni singola volta:
try (BufferedWriter w = Files.newBufferedWriter(path)) {
w.write("hello");
} // close() runs here; tail is flushedSe hai bisogno dei dati su disco prima della chiusura — un watcher della coda del log, o un altro processo che esegue il polling del file — chiama flush() esplicitamente. Il buffer non si svuota automaticamente dopo ogni scrittura; questo è il prezzo dell'avere un buffer.
Mark e reset
Sia BufferedReader che BufferedInputStream supportano una piccola API di "guarda avanti e torna indietro":
in.mark(1024); // remember this position; allow up to 1024 bytes of lookahead
int b = in.read();
in.reset(); // back to the marked positionQuesta è l'unica API di java.io che ti permette di leggere un byte/char e poi rimetterlo al suo posto. È il fondamento del codice che "legge i primi byte per capire il formato" — rilevamento del BOM UTF-8, analisi dei magic number, passaggio tra parser. Senza bufferizzazione non è possibile farlo: gli stream grezzi non conservano più i byte una volta letti.
Quando la bufferizzazione non aiuta
Due casi in cui aggiungere un decoratore Buffered* non porta alcun vantaggio:
- La sorgente è già in memoria.
ByteArrayInputStreameStringReaderservono giàread()da unbyte[]/Stringin memoria; non ci sono syscall da ammortizzare. - Stai usando
Files.readString,Files.readAllBytes,Files.write, otransferTo. Quelle chiamate fanno già il loro I/O a blocchi con un grande buffer interno. Racchiuderle inBufferedInputStreamè ridondante — il JDK ha già bufferizzato.
Il caso in cui la bufferizzazione aiuta è quello originale: stai leggendo o scrivendo piccoli pezzi (un singolo byte, una singola riga, una chiamata printf) e la sorgente/destinazione è un file reale, un socket o una pipe.
Un esempio pratico: lo stesso carico, con e senza
Il programma seguente copia lo stesso blob da 32 KB byte per byte da un file temporaneo a un altro — una volta con FileInputStream/FileOutputStream grezzi, una volta con BufferedInputStream/BufferedOutputStream, una volta con transferTo come riferimento. I tempi misurati rendono visibile il costo del buffer mancante. L'esempio legge poi le righe del file tramite un BufferedReader e dimostra la trappola del "flush dimenticato" lato scrittura.
Cosa ricavare dall'esecuzione:
- La copia grezza byte per byte è stata di ordini di grandezza più lenta di quella bufferizzata. Il corpo del ciclo era identico; l'unica modifica è stata racchiudere gli stream di file in
BufferedInputStream/BufferedOutputStream. Questo è l'unico motivo per cui questi decoratori esistono — stessa API, un numero di syscall enormemente ridotto. transferToè stato veloce quanto la versione bufferizzata (o più veloce). Per "copiare byte da A a B senza trasformazione,"transferToè quello che vuoi — bufferizza già internamente e il JDK ha ottimizzato il ciclo. Usalo prima di scrivere il tuo.Files.newBufferedReaderha restituito direttamente unBufferedReader. Nota che non abbiamo mai scrittonew BufferedReader(new InputStreamReader(new FileInputStream(...), UTF_8))— quello stack a tre livelli è ciò che la factory nasconde.readLine()è venuto fuori da quello stack gratuitamente.- Lo scrittore che perde dati ha stampato
0 bytesprima diflush(). Quei caratteri erano nel buffer in memoria, non su disco. Chiamareflush()li ha inviati; senza il flush esplicito (o unaclose()corretta), sarebbero andati persi. Ecco perchétry-with-resources attorno ai buffered writer non è opzionale — è il contratto che rende la scrittura visibile. - Il ciclo
BufferedReader.readLine()è la forma di elaborazione testo più comune in Java. Memorizza la formawhile ((line = r.readLine()) != null): l'assegnazione nella condizione è idiomatica qui, e il sentinellanull(non una stringa vuota) è la condizione di uscita del ciclo.
Cosa c'è dopo
La bufferizzazione risolve il costo di una syscall per chiamata ma non cambia cosa significano i byte. Il prossimo capitolo, Java DataInput and DataOutput Streams, tratta i decoratori che leggono e scrivono i primitivi Java in un formato binario portabile — il livello che ti permette di scrivere un int su un file e rileggerlo come int su un SO diverso.