1. Lettura del file a blocchi: ByteBuffer e codifica
Oggi abbiamo raramente a che fare con piccoli file di testo. Di solito si tratta di enormi log dei server, report, file CSV o dump di dati dell'ordine dei gigabyte. Perciò è importante non solo leggere il file, ma farlo in modo efficiente e senza «bloccare» l'applicazione.
L'approccio asincrono aiuta proprio in questo: non blocca il thread principale — che si tratti dell'interfaccia o della logica lato server —, permette di leggere e scrivere grandi quantità di dati in parallelo e rende l'applicazione scalabile quando è necessario lavorare con più file contemporaneamente.
La cosa fondamentale da capire: l'I/O asincrono non rende il disco più veloce — i miracoli non esistono. Permette semplicemente al vostro programma di non annoiarsi in attesa che il disco completi l'operazione e di occuparsi di altro nel frattempo.
Come funziona la lettura asincrona?
Un canale asincrono (AsynchronousFileChannel) legge non stringhe, ma blocchi di byte in un oggetto ByteBuffer. È come trasportare scatole di lettere invece di singole parole. Dopo la lettura dovete trasformare questi byte in stringhe — tenendo conto della codifica!
Esempio: lettura asincrona del file a blocchi
Scriviamo un esempio semplice di lettura asincrona del file a blocchi da 4096 byte e di stampa del contenuto in console.
import java.nio.ByteBuffer;
import java.nio.channels.AsynchronousFileChannel;
import java.nio.file.Path;
import java.nio.file.StandardOpenOption;
import java.util.concurrent.Future;
import java.nio.charset.StandardCharsets;
import java.io.IOException;
public class AsyncTextReadExample {
public static void main(String[] args) throws Exception {
Path path = Path.of("bigfile.txt");
try (AsynchronousFileChannel channel = AsynchronousFileChannel.open(path, StandardOpenOption.READ)) {
ByteBuffer buffer = ByteBuffer.allocate(4096);
int position = 0;
Future<Integer> future = channel.read(buffer, position);
while (future.get() > 0) {
buffer.flip();
// Convertiamo i byte in stringa (UTF-8)
String chunk = StandardCharsets.UTF_8.decode(buffer).toString();
System.out.print(chunk);
buffer.clear();
position += chunk.getBytes(StandardCharsets.UTF_8).length;
future = channel.read(buffer, position);
}
}
}
}
Punti importanti:
- Leggiamo il file a parti (per buffer), non per intero.
- Dopo la lettura i byte vengono decodificati in stringa usando Charset.
- Non dimenticare buffer.clear() — altrimenti la successiva read non funzionerà!
Perché non basta semplicemente decodificare i byte?
Il problema è che una stringa può «spezzarsi» tra due blocchi, soprattutto se si usa una codifica multibyte (ad esempio, "UTF-8"). Se l'ultimo byte nel buffer è metà di un carattere, il blocco successivo inizierà con il «resto» del carattere. Senza una gestione speciale otterrete «mojibake» o persino un errore di decodifica.
2. Conversione dei byte in stringhe: gestione delle interruzioni
Il problema dell'interruzione delle stringhe
Supponiamo di avere la stringa "Ciao\nMondo\n", e il buffer finisce su "Cia", mentre "o\nMondo\n" cade nel blocco successivo. Se semplicemente concatenate le stringhe, potete perdere caratteri o ottenere una stringa non valida.
Soluzione: usare CharsetDecoder
Java fornisce la classe CharsetDecoder, che sa gestire correttamente tali casi. «Ricorda» i byte non decodificati e ripristina correttamente i caratteri al confine tra blocchi.
Esempio di utilizzo di CharsetDecoder
import java.nio.charset.Charset;
import java.nio.charset.CharsetDecoder;
import java.nio.CharBuffer;
import java.nio.ByteBuffer;
CharsetDecoder decoder = Charset.forName("UTF-8").newDecoder();
ByteBuffer buffer = ... // i vostri byte
CharBuffer charBuffer = CharBuffer.allocate(buffer.capacity());
decoder.decode(buffer, charBuffer, false);
// Ora charBuffer contiene caratteri decodificati correttamente
Nella pratica conserverete il «residuo» tra le letture e decodificherete tenendo conto di tale residuo.
3. Scrittura asincrona di file di testo
La lettura è solo metà del lavoro. Anche la scrittura viene eseguita a blocchi di byte, che prima vanno ottenuti dalle stringhe (codifica).
Esempio: scrittura asincrona di una stringa su file
import java.nio.ByteBuffer;
import java.nio.channels.AsynchronousFileChannel;
import java.nio.file.Path;
import java.nio.file.StandardOpenOption;
import java.util.concurrent.Future;
import java.nio.charset.StandardCharsets;
public class AsyncTextWriteExample {
public static void main(String[] args) throws Exception {
Path path = Path.of("output.txt");
String text = "Ciao, mondo!\n";
ByteBuffer buffer = ByteBuffer.wrap(text.getBytes(StandardCharsets.UTF_8));
try (AsynchronousFileChannel channel = AsynchronousFileChannel.open(path, StandardOpenOption.WRITE, StandardOpenOption.CREATE)) {
Future<Integer> future = channel.write(buffer, 0);
// Per la demo attendiamo il completamento (di solito non serve!)
future.get();
System.out.println("Dati scritti in modo asincrono.");
}
}
}
Commento: Nei veri scenari asincroni non conviene chiamare future.get() nel thread principale — questo trasforma il codice asincrono in sincrono. Meglio usare CompletionHandler (vedi la lezione precedente).
4. Pratica: lettura asincrona di un grande file di testo e conteggio delle righe
Realizziamo un compito pratico: leggere asincronamente un grande file di testo e contare il numero di righe ("\n"). Risultato: stampare in console il numero di righe.
Esempio con CompletionHandler
import java.nio.ByteBuffer;
import java.nio.channels.AsynchronousFileChannel;
import java.nio.channels.CompletionHandler;
import java.nio.file.Path;
import java.nio.file.StandardOpenOption;
import java.nio.charset.CharsetDecoder;
import java.nio.charset.StandardCharsets;
import java.nio.CharBuffer;
import java.io.IOException;
import java.util.concurrent.atomic.AtomicLong;
public class AsyncLineCounter {
public static void main(String[] args) throws IOException {
Path path = Path.of("bigfile.txt");
AsynchronousFileChannel channel = AsynchronousFileChannel.open(path, StandardOpenOption.READ);
ByteBuffer buffer = ByteBuffer.allocate(4096);
AtomicLong position = new AtomicLong(0);
CharsetDecoder decoder = StandardCharsets.UTF_8.newDecoder();
StringBuilder leftover = new StringBuilder();
AtomicLong lines = new AtomicLong(0);
channel.read(buffer, position.get(), null, new CompletionHandler<Integer, Object>() {
@Override
public void completed(Integer result, Object attachment) {
if (result == -1) {
// File letto fino alla fine
if (leftover.length() > 0) lines.incrementAndGet();
System.out.println("Righe nel file: " + lines.get());
try { channel.close(); } catch (IOException e) { e.printStackTrace(); }
return;
}
buffer.flip();
CharBuffer charBuffer = CharBuffer.allocate(buffer.remaining());
decoder.decode(buffer, charBuffer, false);
charBuffer.flip();
String chunk = leftover.toString() + charBuffer.toString();
leftover.setLength(0);
// Conteggiamo le righe
int last = 0;
int idx;
while ((idx = chunk.indexOf('\n', last)) != -1) {
lines.incrementAndGet();
last = idx + 1;
}
// Residuo (parte della riga dopo l'ultimo \n)
if (last < chunk.length()) {
leftover.append(chunk.substring(last));
}
buffer.clear();
position.addAndGet(result);
channel.read(buffer, position.get(), null, this);
}
@Override
public void failed(Throwable exc, Object attachment) {
System.err.println("Errore di lettura: " + exc.getMessage());
try { channel.close(); } catch (IOException e) { e.printStackTrace(); }
}
});
// Per evitare che il programma termini prima del tempo (solo per l'esempio!)
try { Thread.sleep(2000); } catch (InterruptedException e) {}
}
}
- Usiamo CompletionHandler per codice veramente asincrono.
- Dopo ogni lettura il buffer viene decodificato con CharsetDecoder.
- Il residuo della riga non terminata con "\n" viene riportato al blocco successivo.
- Alla fine del file, se qualcosa è rimasto in leftover, conta anch'esso come riga.
- Per semplicità l'esempio si «addormenta» per 2000 ms, così l'operazione asincrona può concludersi (nelle applicazioni reali non serve — di solito c'è un ciclo principale o una UI).
5. Scrittura asincrona dei risultati su file
Supponiamo di voler scrivere il risultato (ad esempio, il numero di righe) in un nuovo file — anch'esso in modo asincrono.
import java.nio.ByteBuffer;
import java.nio.channels.AsynchronousFileChannel;
import java.nio.channels.CompletionHandler;
import java.nio.file.Path;
import java.nio.file.StandardOpenOption;
import java.nio.charset.StandardCharsets;
import java.io.IOException;
public class AsyncWriteResult {
public static void main(String[] args) throws IOException {
String result = "Righe nel file: 12345\n";
ByteBuffer buffer = ByteBuffer.wrap(result.getBytes(StandardCharsets.UTF_8));
Path path = Path.of("result.txt");
AsynchronousFileChannel channel = AsynchronousFileChannel.open(
path, StandardOpenOption.WRITE, StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING);
channel.write(buffer, 0, null, new CompletionHandler<Integer, Object>() {
@Override
public void completed(Integer written, Object attachment) {
System.out.println("Risultato scritto in modo asincrono!");
try { channel.close(); } catch (IOException e) { e.printStackTrace(); }
}
@Override
public void failed(Throwable exc, Object attachment) {
System.err.println("Errore di scrittura: " + exc.getMessage());
try { channel.close(); } catch (IOException e) { e.printStackTrace(); }
}
});
try { Thread.sleep(500); } catch (InterruptedException e) {}
}
}
6. Consigli per la gestione di dati parziali e delle codifiche
Stringhe parziali tra i blocchi
Se una stringa è divisa tra due blocchi, non cercate di «incollare» i byte manualmente! Usate CharsetDecoder, che gestirà con cura i byte mancanti senza perdere alcun carattere.
Lavorare con diverse codifiche
"UTF-8" — è lo standard per le applicazioni moderne, ma se il file usa un'altra codifica (ad esempio, "Windows-1251" o "UTF-16"), usate il relativo Charset:
import java.nio.charset.Charset;
import java.nio.charset.CharsetDecoder;
Charset charset = Charset.forName("Windows-1251");
CharsetDecoder decoder = charset.newDecoder();
Uso di CharsetDecoder e CharsetEncoder
Quando leggete o scrivete dati a parti, è importante lavorare correttamente con la codifica. Un carattere può «spezzarsi» tra due blocchi e, senza ulteriore gestione, ne risulterà un pasticcio di byte.
Per evitarlo si usano CharsetDecoder e CharsetEncoder.
In lettura si chiama decode(ByteBuffer, CharBuffer, endOfInput), mentre in scrittura — encode(CharBuffer, ByteBuffer, endOfInput).
Si occupano di far sì che, anche se un carattere è stato diviso tra due blocchi, venga comunque ricomposto e gestito correttamente.
7. Errori tipici nell'elaborazione asincrona dei file di testo
Errore n. 1: Ignorare i residui di riga. Se non si conserva la «coda» della riga tra i blocchi, parte delle righe può andare persa o essere decodificata in modo non corretto.
Errore n. 2: Uso scorretto del buffer. Dimenticare di chiamare buffer.clear() dopo l'elaborazione — la prossima read non funzionerà o i dati saranno non corretti.
Errore n. 3: Codifica non adatta. Se i byte vengono decodificati con un Charset diverso da quello con cui il file è stato scritto, sono possibili «mojibake» o anche errori.
Errore n. 4: Blocco del thread principale. Se chiamate future.get() o Thread.sleep() nel thread della UI, perdete il senso dell'asincronia. Usate CompletionHandler e approcci reattivi.
Errore n. 5: Canale non chiuso al termine. Chiudete sempre il canale (channel.close()) dopo che tutte le operazioni sono terminate, anche in presenza di errore.
GO TO FULL VERSION