Perché Java IO è così cattivo?

L'API IO (Input & Output) è un'API Java che semplifica agli sviluppatori il lavoro con i flussi. Diciamo che riceviamo alcuni dati (ad esempio, nome, secondo nome, cognome) e dobbiamo scriverli in un file: è giunto il momento di utilizzare java.io .

Struttura della libreria java.io

Ma Java IO ha i suoi svantaggi, quindi parliamo di ciascuno di essi a turno:

  1. Blocco dell'accesso per input/output. Il problema è che quando uno sviluppatore tenta di leggere o scrivere qualcosa su un file utilizzando Java IO , blocca il file e ne blocca l'accesso fino al termine del lavoro.
  2. Nessun supporto per i file system virtuali.
  3. Nessun supporto per i collegamenti.
  4. Un sacco di eccezioni verificate.

Lavorare con i file implica sempre lavorare con le eccezioni: ad esempio, provare a creare un nuovo file già esistente genererà un'eccezione IOException . In questo caso, l'applicazione dovrebbe continuare a funzionare e l'utente dovrebbe essere avvisato del motivo per cui non è stato possibile creare il file.


try {
	File.createTempFile("prefix", "");
} catch (IOException e) {
	// Handle the IOException
}

/**
 * Creates an empty file in the default temporary-file directory 
 * any exceptions will be ignored. This is typically used in finally blocks. 
 * @param prefix 
 * @param suffix 
 * @throws IOException - If a file could not be created
 */
public static File createTempFile(String prefix, String suffix) 
throws IOException {
...
}

Qui vediamo che il metodo createTempFile lancia una IOException quando il file non può essere creato. Questa eccezione deve essere gestita in modo appropriato. Se proviamo a chiamare questo metodo all'esterno di un blocco try-catch , il compilatore genererà un errore e suggerirà due opzioni per risolverlo: avvolgere il metodo in un blocco try-catch o fare in modo che il metodo che chiama File.createTempFile lanci un'eccezione IOException ( quindi può essere gestito a un livello superiore).

Arrivando a Java NIO e come si confronta con Java IO

Java NIO , o Java Non-Blocking I/O (o talvolta Java New I/O) è progettato per operazioni di I/O ad alte prestazioni.

Confrontiamo i metodi Java IO e quelli che li sostituiscono.

Innanzitutto, parliamo di come lavorare con Java IO :

Classe InputStream


try(FileInputStream fin = new FileInputStream("C:/codegym/file.txt")){
    System.out.printf("File size: %d bytes \n", fin.available());
    int i=-1;
    while((i=fin.read())!=-1) {
        System.out.print((char)i);
    }   
} catch(IOException ex) {
    System.out.println(ex.getMessage());
}

La classe FileInputStream serve per leggere i dati da un file. Eredita la classe InputStream e quindi implementa tutti i suoi metodi. Se il file non può essere aperto, viene generata un'eccezione FileNotFoundException .

Classe OutputStream


String text = "Hello world!"; // String to write
try(FileOutputStream fos = new FileOutputStream("C:/codegym/file.txt")){
    // Convert our string into bytes
    byte[] buffer = text.getBytes();
    fos.write(buffer, 0, buffer.length);
    System.out.println("The file has been written");
} catch(IOException ex) {
    System.out.println(ex.getMessage());
}

La classe FileOutputStream per la scrittura di byte in un file. Deriva dalla classe OutputStream .

Corsi di lettura e scrittura

La classe FileReader ci consente di leggere i dati dei caratteri dai flussi e la classe FileWriter viene utilizzata per scrivere i flussi di caratteri. Il codice seguente mostra come scrivere e leggere da un file:


        String fileName = "c:/codegym/Example.txt";

        // Create a FileWriter object
        try (FileWriter writer = new FileWriter(fileName)) {

            // Write content to file
            writer.write("This is a simple example\nin which we\nwrite to a file\nand read from a file\n");
            writer.flush();
        } catch (IOException e) {
            e.printStackTrace();
        }

        // Create a FileReader object
        try (FileReader fr = new FileReader(fileName)) {
            char[] a = new char[200]; // Number of characters to read
            fr.read(a); // Read content into an array
            for (char c : a) {
                System.out.print(c); // Display characters one by one
            }
        } catch (IOException e) {
            e.printStackTrace();
        }

Ora parliamo di Java NIO :

Canale

A differenza dei flussi utilizzati in Java IO , Channel è un'interfaccia a due vie, ovvero può sia leggere che scrivere. Un canale Java NIO supporta il flusso di dati asincrono sia in modalità bloccante che non bloccante.


RandomAccessFile aFile = new RandomAccessFile("C:/codegym/file.txt", "rw");
FileChannel inChannel = aFile.getChannel();

ByteBuffer buf = ByteBuffer.allocate(100);
int bytesRead = inChannel.read(buf);

while (bytesRead != -1) {
  System.out.println("Read: " + bytesRead);
  buf.flip();
	  while(buf.hasRemaining()) {
	      System.out.print((char) buf.get());
	  }
  buf.clear();
  bytesRead = inChannel.read(buf);
}
aFile.close();

Qui abbiamo usato un FileChannel . Usiamo un canale file per leggere i dati da un file. Un oggetto canale file può essere creato solo chiamando il metodo getChannel() su un oggetto file — non c'è modo di creare direttamente un oggetto canale file.

Oltre a FileChannel , abbiamo altre implementazioni di canale:

  • FileChannel — per lavorare con i file

  • DatagramChannel : un canale per lavorare su una connessione UDP

  • SocketChannel — un canale per lavorare su una connessione TCP

  • ServerSocketChannel contiene un SocketChannel ed è simile a come funziona un server web

Nota: FileChannel non può essere commutato in modalità non bloccante. La modalità non bloccante di Java NIO ti consente di richiedere dati di lettura da un canale e ricevere solo ciò che è attualmente disponibile (o niente se non ci sono ancora dati disponibili) . Detto questo, SelectableChannel e le sue implementazioni possono essere messe in modalità non bloccante usando il metodo connect() .

Selettore

Java NIO ha introdotto la possibilità di creare un thread che sa quale canale è pronto per scrivere e leggere dati e può elaborare quel particolare canale. Questa abilità è implementata usando la classe Selector .

Collegamento dei canali a un selettore


Selector selector = Selector.open();
channel.configureBlocking(false); // Non-blocking mode
SelectionKey key = channel.register(selector, SelectionKey.OP_READ);

Quindi creiamo il nostro Selector e lo colleghiamo a SelectableChannel .

Per essere utilizzato con un selettore, un canale deve essere in modalità non bloccante. Ciò significa che non è possibile utilizzare FileChannel con un selettore, poiché FileChannel non può essere messo in modalità non bloccante. Ma i canali socket funzioneranno bene.

Qui menzioniamo che nel nostro esempio SelectionKey è un insieme di operazioni che possono essere eseguite su un canale. Il tasto di selezione ci permette di conoscere lo stato di un canale.

Tipi di SelectionKey

  • SelectionKey.OP_CONNECT indica un canale pronto per la connessione al server.

  • SelectionKey.OP_ACCEPT è un canale pronto ad accettare connessioni in entrata.

  • SelectionKey.OP_READ indica un canale pronto a leggere i dati.

  • SelectionKey.OP_WRITE indica un canale pronto a scrivere dati.

Respingente

I dati vengono letti in un buffer per un'ulteriore elaborazione. Uno sviluppatore può spostarsi avanti e indietro nel buffer, il che ci offre un po' più di flessibilità durante l'elaborazione dei dati. Allo stesso tempo, dobbiamo verificare se il buffer contiene la quantità di dati necessaria per una corretta elaborazione. Inoltre, quando si leggono i dati in un buffer, assicurarsi di non distruggere i dati esistenti che non sono stati ancora elaborati.


ByteBuffer buf = ByteBuffer.allocate (2048); 
int bytesRead = channel.read(buf);
buf.flip(); // Change to read mode
while (buf.hasRemaining()) { 
	byte data = buf.get(); // There are methods for primitives 
}

buf.clear(); // Clear the buffer - now it can be reused

Proprietà di base di un buffer:

Attributi di base
capacità La dimensione del buffer, che è la lunghezza dell'array.
posizione La posizione di partenza per lavorare con i dati.
limite Il limite operativo. Per le operazioni di lettura, il limite è la quantità di dati che possono essere letti, ma per le operazioni di scrittura è la capacità o la quota disponibile per la scrittura.
segno L'indice del valore a cui verrà reimpostato il parametro position quando viene chiamato il metodo reset() .

Ora parliamo un po' delle novità di Java NIO.2 .

Sentiero

Path rappresenta un percorso nel file system. Contiene il nome di un file e un elenco di directory che ne definiscono il percorso.


Path relative = Paths.get("Main.java");
System.out.println("File: " + relative);
// Get the file system
System.out.println(relative.getFileSystem());

Paths è una classe molto semplice con un unico metodo statico: get() . È stato creato esclusivamente per ottenere un oggetto Path dalla stringa passata o dall'URI.


Path path = Paths.get("c:\\data\\file.txt");

File

Files è una classe di utilità che ci consente di ottenere direttamente la dimensione di un file, copiare file e altro.


Path path = Paths.get("files/file.txt");
boolean pathExists = Files.exists(path);

FileSystem

FileSystem fornisce un'interfaccia al file system. FileSystem funziona come una fabbrica per la creazione di vari oggetti (Sentiero,PathMatcher,File). Ci aiuta ad accedere a file e altri oggetti nel file system.


try {
      FileSystem filesystem = FileSystems.getDefault();
      for (Path rootdir : filesystem.getRootDirectories()) {
          System.out.println(rootdir.toString());
      }
  } catch (Exception e) {
      e.printStackTrace();
  }

Test della prestazione

Per questo test, prendiamo due file. Il primo è un piccolo file di testo e il secondo è un video di grandi dimensioni.

Creeremo un file e aggiungeremo alcune parole e caratteri:

% toccare testo.txt

Il nostro file occupa un totale di 42 byte in memoria:

Ora scriviamo il codice che copierà il nostro file da una cartella all'altra. Proviamolo sui file piccoli e grandi per confrontare la velocità di IO e NIO e NIO.2 .

Codice per la copia, scritto utilizzando Java IO :


public static void main(String[] args) {
        long currentMills = System.currentTimeMillis();
        long startMills = currentMills;
        File src = new File("/Users/IdeaProjects/testFolder/text.txt");
        File dst = new File("/Users/IdeaProjects/testFolder/text1.txt");
        copyFileByIO(src, dst);
        currentMills = System.currentTimeMillis();
        System.out.println("Execution time in milliseconds: " + (currentMills - startMills));
    }

    public static void copyFileByIO(File src, File dst) {
        try(InputStream inputStream = new FileInputStream(src);
            OutputStream outputStream = new FileOutputStream(dst)){

            byte[] buffer = new byte[1024];
            int length;
            // Read data into a byte array and then output to an OutputStream
            while((length = inputStream.read(buffer)) > 0) {
                outputStream.write(buffer, 0, length);
            }
        } catch (IOException e) {
            e.printStackTrace();
        }
    }

Ed ecco il codice per Java NIO :


public static void main(String[] args) {
        long currentMills = System.currentTimeMillis();
        long startMills = currentMills;

        File src = new File("/Users/IdeaProjects/testFolder/text.txt");
        File dst = new File("/Users/IdeaProjects/testFolder/text2.txt");
        // Code for copying using NIO
        copyFileByChannel(src, dst);
        currentMills = System.currentTimeMillis();
        System.out.println("Execution time in milliseconds: " + (currentMills - startMills));
    }

    public static void copyFileByChannel(File src, File dst) {
        // 1. Get a FileChannel for the source file and the target file
        try(FileChannel srcFileChannel  = new FileInputStream(src).getChannel();
            FileChannel dstFileChannel = new FileOutputStream(dst).getChannel()){
            // 2. Size of the current FileChannel
            long count = srcFileChannel.size();
            while(count > 0) {
                /**=============================================================
                 * 3. Write bytes from the source file's FileChannel to the target FileChannel
                 * 1. srcFileChannel.position(): the starting position in the source file, cannot be negative
                 * 2. count: the maximum number of bytes transferred, cannot be negative
                 * 3. dstFileChannel: the target file
                 *==============================================================*/
                long transferred = srcFileChannel.transferTo(srcFileChannel.position(),
                        count, dstFileChannel);
                // 4. After the transfer is complete, change the position of the original file to the new one
                srcFileChannel.position(srcFileChannel.position() + transferred);
                // 5. Calculate how many bytes are left to transfer
                count -= transferred;
            }

        } catch (FileNotFoundException e) {
            e.printStackTrace();
        } catch (IOException e) {
            e.printStackTrace();
        }
    }

Codice per Java NIO.2 :


public static void main(String[] args) {
  long currentMills = System.currentTimeMillis();
  long startMills = currentMills;

  Path sourceDirectory = Paths.get("/Users/IdeaProjects/testFolder/test.txt");
  Path targetDirectory = Paths.get("/Users/IdeaProjects/testFolder/test3.txt");
  Files.copy(sourceDirectory, targetDirectory);

  currentMills = System.currentTimeMillis();
  System.out.println("Execution time in milliseconds: " + (currentMills - startMills));
}

Iniziamo con il file piccolo.

Il tempo di esecuzione per Java IO è stato in media di 1 millisecondo. Eseguendo il test più volte, otteniamo risultati da 0 a 2 millisecondi.

Tempo di esecuzione in millisecondi: 1

Il tempo di esecuzione per Java NIO è molto più lungo. Il tempo medio è di 11 millisecondi. I risultati variavano da 9 a 16. Questo perché Java IO funziona in modo diverso rispetto al nostro sistema operativo. IO sposta ed elabora i file uno per uno, ma il sistema operativo invia i dati in un unico blocco. NIO ha funzionato male perché è orientato al buffer, non al flusso come IO .

Tempo di esecuzione in millisecondi: 12

Ed eseguiamo anche il nostro test per Java NIO.2 . NIO.2 ha migliorato la gestione dei file rispetto a Java NIO . Questo è il motivo per cui la libreria aggiornata produce risultati così diversi:

Tempo di esecuzione in millisecondi: 3

Ora proviamo a testare il nostro file di grandi dimensioni, un video da 521 MB. Il compito sarà esattamente lo stesso: copiare il file in un'altra cartella. Aspetto!

Risultati per Java IO :

Tempo di esecuzione in millisecondi: 1866

Ed ecco il risultato per Java NIO :

Tempo di esecuzione in millisecondi: 205

Java NIO ha gestito il file 9 volte più velocemente nel primo test. Test ripetuti hanno mostrato approssimativamente gli stessi risultati.

E proveremo anche il nostro test su Java NIO.2 :

Tempo di esecuzione in millisecondi: 360

Perché questo risultato? Semplicemente perché non ha molto senso per noi confrontare le prestazioni tra di loro, dal momento che servono a scopi diversi. NIO è un I/O di basso livello più astratto, mentre NIO.2 è orientato alla gestione dei file.

Riepilogo

Possiamo tranquillamente affermare che Java NIO è significativamente più efficiente quando si lavora con i file grazie all'uso all'interno dei blocchi. Un altro vantaggio è che la libreria NIO è divisa in due parti: una per lavorare con i file, un'altra per lavorare con la rete.

La nuova API di Java NIO.2 per lavorare con i file offre molte funzioni utili:

  • indirizzamento del file system molto più utile utilizzando Path ,

  • gestione notevolmente migliorata dei file ZIP utilizzando un provider di file system personalizzato,

  • accesso ad attributi di file speciali,

  • molti metodi convenienti, come leggere un intero file con una singola istruzione, copiare un file con una singola istruzione, ecc.

Si tratta di file e file system, ed è tutto piuttosto di alto livello.

La realtà oggi è che Java NIO rappresenta circa l'80-90% del lavoro con i file, sebbene la quota di Java IO sia ancora significativa.

💡 PS Questi test sono stati eseguiti su un MacBook Pro 14" 16/512. I risultati dei test possono differire in base al sistema operativo e alle specifiche della workstation.