NIO (New Input/Output) in Java

Java NIO (New Input/Output) rappresenta una reimplementazione completa del sistema I/O di Java, introdotta per superare le limitazioni dell’I/O tradizionale stream-based. NIO offre operazioni non-bloccanti, gestione efficiente di multiple connessioni e un modello di programmazione orientato ai buffer che è particolarmente adatto per applicazioni server ad alte performance.
Concetti Fondamentali
NIO si basa su tre componenti principali: Channels (canali), Buffers (buffer) e Selectors (selettori). Questo approccio differisce radicalmente dall’I/O tradizionale, passando da un modello stream-oriented a uno buffer-oriented, da blocking I/O a non-blocking I/O.
Differenze con I/O Tradizionale
Stream vs Channel: Gli stream tradizionali sono unidirezionali (input o output), mentre i canali sono bidirezionali e possono leggere e scrivere simultaneamente.
Blocking vs Non-blocking: L’I/O tradizionale blocca il thread fino al completamento dell’operazione, mentre NIO permette operazioni non-bloccanti.
Buffer-oriented: NIO lavora con buffer che contengono dati, permettendo elaborazione più efficiente e controllo granulare.
// I/O tradizionale - blocking
try (FileInputStream fis = new FileInputStream("file.txt");
BufferedInputStream bis = new BufferedInputStream(fis)) {
int data;
while ((data = bis.read()) != -1) { // Blocca fino a dati disponibili
// Elabora byte per byte
}
}
// NIO - non-blocking potenziale
try (FileChannel channel = FileChannel.open(Paths.get("file.txt"))) {
ByteBuffer buffer = ByteBuffer.allocate(1024);
while (channel.read(buffer) > 0) { // Può essere non-bloccante
buffer.flip();
// Elabora tutto il buffer
buffer.clear();
}
}
Buffer
I buffer sono contenitori per dati di un tipo primitivo specifico. Funzionano come array con metadati aggiuntivi che tracciano la posizione corrente, il limite e la capacità.
Tipi di Buffer
Java NIO fornisce buffer specializzati per ogni tipo primitivo: ByteBuffer, CharBuffer, IntBuffer, FloatBuffer, DoubleBuffer, LongBuffer, ShortBuffer.
Stati e Operazioni del Buffer
Ogni buffer mantiene quattro attributi fondamentali:
Capacity: Dimensione massima del buffer (fissa). Position: Prossima posizione per lettura/scrittura. Limit: Prima posizione che non deve essere letta/scritta. Mark: Posizione memorizzata per reset successivo.
// Creazione e utilizzo di buffer
ByteBuffer buffer = ByteBuffer.allocate(1024); // Capacity = 1024
// Scrittura nel buffer
buffer.put("Hello World".getBytes());
// Position avanza, limit = capacity
// Preparazione per lettura
buffer.flip(); // Position = 0, limit = posizione precedente
// Lettura dal buffer
byte[] data = new byte[buffer.remaining()];
buffer.get(data);
// Reset per nuova scrittura
buffer.clear(); // Position = 0, limit = capacity
// Operazioni avanzate
buffer.mark(); // Memorizza posizione corrente
buffer.position(10); // Sposta posizione
buffer.reset(); // Torna alla posizione marcata
buffer.rewind(); // Position = 0, mantiene limit
Direct vs Heap Buffers
Heap Buffers: Allocati nell’heap Java, soggetti a garbage collection, più lenti per I/O ma più veloci per manipolazione in memoria.
Direct Buffers: Allocati fuori dall’heap, non soggetti a GC, più veloci per I/O ma costosi da allocare.
// Heap buffer (default)
ByteBuffer heapBuffer = ByteBuffer.allocate(1024);
// Direct buffer - migliore per I/O intensivo
ByteBuffer directBuffer = ByteBuffer.allocateDirect(1024);
// Controllo del tipo
if (directBuffer.isDirect()) {
System.out.println("Buffer allocato fuori heap");
}
// Memory-mapped buffer per file grandi
try (RandomAccessFile file = new RandomAccessFile("largefile.dat", "rw");
FileChannel channel = file.getChannel()) {
MappedByteBuffer mappedBuffer = channel.map(
FileChannel.MapMode.READ_WRITE, 0, file.length());
// Accesso diretto alla memoria del file
mappedBuffer.put(0, (byte) 42);
}
Channels
I canali rappresentano connessioni a entità che supportano operazioni I/O come file, socket di rete o dispositivi hardware. Sono più flessibili degli stream tradizionali e supportano operazioni non-bloccanti.
Tipi di Channel
FileChannel: Per operazioni su file, supporta lettura, scrittura, mapping in memoria e locking.
SocketChannel: Per connessioni TCP client, supporta modalità bloccante e non-bloccante.
ServerSocketChannel: Per server TCP che accettano connessioni.
DatagramChannel: Per comunicazione UDP.
// FileChannel per operazioni su file
try (FileChannel channel = FileChannel.open(Paths.get("data.txt"),
StandardOpenOption.READ,
StandardOpenOption.WRITE)) {
ByteBuffer buffer = ByteBuffer.allocate(1024);
// Lettura
int bytesRead = channel.read(buffer);
// Scrittura
buffer.flip();
channel.write(buffer);
// Trasferimento diretto tra canali
try (FileChannel targetChannel = FileChannel.open(Paths.get("copy.txt"),
StandardOpenOption.CREATE,
StandardOpenOption.WRITE)) {
channel.transferTo(0, channel.size(), targetChannel);
}
}
// SocketChannel per comunicazione di rete
try (SocketChannel channel = SocketChannel.open()) {
channel.connect(new InetSocketAddress("example.com", 80));
ByteBuffer request = ByteBuffer.wrap("GET / HTTP/1.1\r\n\r\n".getBytes());
channel.write(request);
ByteBuffer response = ByteBuffer.allocate(1024);
channel.read(response);
}
Operazioni Non-bloccanti
I canali di rete supportano modalità non-bloccante, permettendo di gestire multiple connessioni con un singolo thread.
// Server non-bloccante basilare
ServerSocketChannel serverChannel = ServerSocketChannel.open();
serverChannel.bind(new InetSocketAddress(8080));
serverChannel.configureBlocking(false); // Modalità non-bloccante
while (true) {
SocketChannel clientChannel = serverChannel.accept();
if (clientChannel != null) {
clientChannel.configureBlocking(false);
ByteBuffer buffer = ByteBuffer.allocate(1024);
int bytesRead = clientChannel.read(buffer);
if (bytesRead > 0) {
// Elabora dati senza bloccare
buffer.flip();
clientChannel.write(buffer);
}
clientChannel.close();
}
// Continua il loop senza bloccare
Thread.sleep(10); // Evita busy waiting
}
Selectors
I selettori permettono di monitorare multiple canali con un singolo thread, identificando quali canali sono pronti per operazioni I/O. Questo è il meccanismo chiave per scalabilità in applicazioni server.
Multiplexing I/O
Un selettore può monitorare migliaia di canali simultaneamente, notificando quando sono pronti per lettura, scrittura o accettazione di nuove connessioni.
public class NIOServer {
public static void main(String[] args) throws IOException {
// Configura server
ServerSocketChannel serverChannel = ServerSocketChannel.open();
serverChannel.bind(new InetSocketAddress(8080));
serverChannel.configureBlocking(false);
// Crea selettore
Selector selector = Selector.open();
serverChannel.register(selector, SelectionKey.OP_ACCEPT);
System.out.println("Server avviato sulla porta 8080");
while (true) {
// Attende eventi (bloccante, ma gestisce multiple connessioni)
int readyChannels = selector.select();
if (readyChannels == 0) continue;
Set<SelectionKey> selectedKeys = selector.selectedKeys();
Iterator<SelectionKey> keyIterator = selectedKeys.iterator();
while (keyIterator.hasNext()) {
SelectionKey key = keyIterator.next();
try {
if (key.isAcceptable()) {
handleAccept(key);
} else if (key.isReadable()) {
handleRead(key);
} else if (key.isWritable()) {
handleWrite(key);
}
} catch (IOException e) {
key.cancel();
key.channel().close();
}
keyIterator.remove();
}
}
}
private static void handleAccept(SelectionKey key) throws IOException {
ServerSocketChannel serverChannel = (ServerSocketChannel) key.channel();
SocketChannel clientChannel = serverChannel.accept();
if (clientChannel != null) {
clientChannel.configureBlocking(false);
clientChannel.register(key.selector(), SelectionKey.OP_READ);
System.out.println("Nuova connessione: " + clientChannel.getRemoteAddress());
}
}
private static void handleRead(SelectionKey key) throws IOException {
SocketChannel channel = (SocketChannel) key.channel();
ByteBuffer buffer = ByteBuffer.allocate(1024);
int bytesRead = channel.read(buffer);
if (bytesRead > 0) {
buffer.flip();
// Echo dei dati ricevuti
channel.write(buffer);
// Prepara per prossima lettura
buffer.clear();
} else if (bytesRead < 0) {
// Client ha chiuso connessione
key.cancel();
channel.close();
}
}
private static void handleWrite(SelectionKey key) throws IOException {
// Gestione scrittura quando il canale è pronto
SocketChannel channel = (SocketChannel) key.channel();
// Implementa logica di scrittura
}
}
File System API (NIO.2)
Java 7 ha introdotto NIO.2, una estensione che fornisce un’API moderna per operazioni sul file system, risolvendo molte limitazioni dell’API File tradizionale.
Path e Files
Path: Rappresenta un percorso nel file system, indipendente dalla piattaforma.
Files: Classe utility con metodi statici per operazioni comuni sui file.
// Creazione e manipolazione di Path
Path path = Paths.get("documents", "projects", "readme.txt");
Path absolutePath = path.toAbsolutePath();
Path parent = path.getParent();
Path fileName = path.getFileName();
// Operazioni sui file con Files
Path file = Paths.get("example.txt");
// Controlli di esistenza e proprietà
if (Files.exists(file)) {
boolean isReadable = Files.isReadable(file);
boolean isWritable = Files.isWritable(file);
long size = Files.size(file);
FileTime lastModified = Files.getLastModifiedTime(file);
}
// Lettura e scrittura semplificate
List<String> lines = Files.readAllLines(file, StandardCharsets.UTF_8);
Files.write(file, Arrays.asList("Linea 1", "Linea 2"), StandardCharsets.UTF_8);
// Copia e spostamento
Path destination = Paths.get("copy.txt");
Files.copy(file, destination, StandardCopyOption.REPLACE_EXISTING);
Files.move(file, Paths.get("moved.txt"), StandardCopyOption.ATOMIC_MOVE);
// Creazione directory
Path directory = Paths.get("new-directory");
Files.createDirectories(directory);
Attraversamento Directory
// Traversal ricorsivo con Stream API
try (Stream<Path> paths = Files.walk(Paths.get("/"))) {
paths.filter(Files::isRegularFile)
.filter(p -> p.toString().endsWith(".java"))
.limit(100)
.forEach(System.out::println);
}
// Visitor pattern per controllo completo
Files.walkFileTree(Paths.get("/project"), new SimpleFileVisitor<Path>() {
@Override
public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) {
if (file.toString().endsWith(".tmp")) {
try {
Files.delete(file);
System.out.println("Deleted: " + file);
} catch (IOException e) {
System.err.println("Cannot delete: " + file);
}
}
return FileVisitResult.CONTINUE;
}
@Override
public FileVisitResult visitFileFailed(Path file, IOException exc) {
System.err.println("Failed to visit: " + file);
return FileVisitResult.CONTINUE;
}
});
Watch Service
Il Watch Service permette di monitorare cambiamenti nel file system, ricevendo notifiche quando file o directory vengono creati, modificati o eliminati.
public class FileWatcher {
public static void main(String[] args) throws IOException, InterruptedException {
WatchService watchService = FileSystems.getDefault().newWatchService();
Path directory = Paths.get("watched-directory");
directory.register(watchService,
StandardWatchEventKinds.ENTRY_CREATE,
StandardWatchEventKinds.ENTRY_DELETE,
StandardWatchEventKinds.ENTRY_MODIFY);
System.out.println("Watching directory: " + directory);
while (true) {
WatchKey key = watchService.take(); // Blocca fino a eventi
for (WatchEvent<?> event : key.pollEvents()) {
WatchEvent.Kind<?> kind = event.kind();
Path eventPath = (Path) event.context();
System.out.println(kind + ": " + eventPath);
if (kind == StandardWatchEventKinds.ENTRY_CREATE) {
handleFileCreated(directory.resolve(eventPath));
} else if (kind == StandardWatchEventKinds.ENTRY_MODIFY) {
handleFileModified(directory.resolve(eventPath));
} else if (kind == StandardWatchEventKinds.ENTRY_DELETE) {
handleFileDeleted(eventPath);
}
}
boolean valid = key.reset();
if (!valid) {
break; // Directory non più accessibile
}
}
}
private static void handleFileCreated(Path file) {
System.out.println("New file created: " + file);
}
private static void handleFileModified(Path file) {
System.out.println("File modified: " + file);
}
private static void handleFileDeleted(Path file) {
System.out.println("File deleted: " + file);
}
}
Performance e Best Practices
Ottimizzazioni
Use Direct Buffers for I/O: Per operazioni I/O intensive, i direct buffer offrono performance migliori.
Buffer Pooling: Riutilizza buffer per evitare allocazioni frequenti.
Appropriate Buffer Sizes: Dimensioni buffer ottimali dipendono dal caso d’uso (tipicamente 8KB-64KB).
// Pool di buffer per performance
public class BufferPool {
private final Queue<ByteBuffer> pool = new ConcurrentLinkedQueue<>();
private final int bufferSize;
public BufferPool(int bufferSize, int poolSize) {
this.bufferSize = bufferSize;
for (int i = 0; i < poolSize; i++) {
pool.offer(ByteBuffer.allocateDirect(bufferSize));
}
}
public ByteBuffer acquire() {
ByteBuffer buffer = pool.poll();
return buffer != null ? buffer : ByteBuffer.allocateDirect(bufferSize);
}
public void release(ByteBuffer buffer) {
buffer.clear();
pool.offer(buffer);
}
}
// Utilizzo ottimizzato per lettura file
public static void readFileOptimized(Path file) throws IOException {
try (FileChannel channel = FileChannel.open(file)) {
ByteBuffer buffer = ByteBuffer.allocateDirect(8192); // 8KB buffer
while (channel.read(buffer) > 0) {
buffer.flip();
// Elabora dati nel buffer
processBuffer(buffer);
buffer.clear();
}
}
}
Gestione Errori e Risorse
// Gestione corretta delle risorse NIO
public class ResourceManagement {
public static void safeChannelOperation() {
FileChannel channel = null;
try {
channel = FileChannel.open(Paths.get("file.txt"));
ByteBuffer buffer = ByteBuffer.allocate(1024);
// Operazioni sul canale
channel.read(buffer);
} catch (IOException e) {
System.err.println("Errore I/O: " + e.getMessage());
} finally {
if (channel != null) {
try {
channel.close();
} catch (IOException e) {
System.err.println("Errore chiusura canale: " + e.getMessage());
}
}
}
}
// Preferibile: try-with-resources
public static void modernResourceManagement() {
try (FileChannel channel = FileChannel.open(Paths.get("file.txt"))) {
ByteBuffer buffer = ByteBuffer.allocate(1024);
channel.read(buffer);
} catch (IOException e) {
System.err.println("Errore: " + e.getMessage());
}
}
}
Limitazioni e Considerazioni
Complessità: NIO è più complesso dell’I/O tradizionale e richiede maggiore comprensione.
Non Sempre Migliore: Per operazioni I/O semplici, l’I/O tradizionale può essere più appropriato.
Memory Management: I direct buffer non sono soggetti a GC ma richiedono gestione manuale.
Platform Dependencies: Alcune funzionalità possono avere comportamenti diversi su piattaforme diverse.
Conclusione
Java NIO rappresenta un’evoluzione significativa delle capacità I/O di Java, offrendo strumenti potenti per applicazioni ad alte performance, server scalabili e operazioni file system moderne. Sebbene la curva di apprendimento sia più ripida rispetto all’I/O tradizionale, i benefici in termini di performance e scalabilità sono sostanziali per applicazioni appropriate.
La scelta tra I/O tradizionale e NIO dipende dai requisiti specifici: NIO eccelle in scenari con molte connessioni concorrenti, operazioni file grandi, o quando è necessario controllo granulare sulle operazioni I/O. Per applicazioni semplici o prototipazione rapida, l’I/O tradizionale rimane una scelta valida e più semplice.