NIO (New Input/Output) in Java

Edoardo Midali
Edoardo Midali

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.