Por que o Java IO é tão ruim?

A API IO (Input & Output) é uma API Java que facilita o trabalho dos desenvolvedores com streams. Digamos que recebemos alguns dados (por exemplo, nome, nome do meio, sobrenome) e precisamos gravá-los em um arquivo — chegou a hora de usar java.io .

Estrutura da biblioteca java.io

Mas o Java IO tem suas desvantagens, então vamos falar sobre cada um deles:

  1. Bloqueio de acesso para entrada/saída. O problema é que, quando um desenvolvedor tenta ler ou gravar algo em um arquivo usando Java IO , ele bloqueia o arquivo e bloqueia o acesso a ele até que o trabalho seja concluído.
  2. Sem suporte para sistemas de arquivos virtuais.
  3. Não há suporte para links.
  4. Muitas e muitas exceções verificadas.

Trabalhar com arquivos sempre envolve trabalhar com exceções: por exemplo, tentar criar um novo arquivo que já existe lançará um IOException . Nesse caso, o aplicativo deve continuar em execução e o usuário deve ser notificado porque o arquivo não pôde ser criado.


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 {
...
}

Aqui vemos que o método createTempFile lança uma IOException quando o arquivo não pode ser criado. Essa exceção deve ser tratada adequadamente. Se tentarmos chamar esse método fora de um bloco try-catch , o compilador gerará um erro e sugerirá duas opções para corrigi-lo: agrupar o método em um bloco try-catch ou fazer com que o método que chama File.createTempFile lance uma IOException ( para que possa ser tratado em um nível superior).

Chegando ao Java NIO e como ele se compara ao Java IO

Java NIO , ou Java Non-Blocking I/O (ou às vezes Java New I/O) é projetado para operações de E/S de alto desempenho.

Vamos comparar os métodos Java IO e aqueles que os substituem.

Primeiro, vamos falar sobre como trabalhar com 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());
}

A classe FileInputStream é para ler dados de um arquivo. Ele herda a classe InputStream e, portanto, implementa todos os seus métodos. Se o arquivo não puder ser aberto, uma FileNotFoundException será lançada.

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());
}

A classe FileOutputStream para gravar bytes em um arquivo. Ele deriva da classe OutputStream .

Classes Leitor e Escritor

A classe FileReader nos permite ler dados de caracteres de fluxos, e a classe FileWriter é usada para gravar fluxos de caracteres. O código a seguir mostra como escrever e ler de um arquivo:


        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();
        }

Agora vamos falar sobre Java NIO :

Canal

Ao contrário dos fluxos usados ​​no Java IO , Channel é uma interface bidirecional, ou seja, pode ler e escrever. Um canal Java NIO suporta fluxo de dados assíncrono nos modos de bloqueio e não bloqueio.


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();

Aqui usamos um FileChannel . Usamos um canal de arquivo para ler dados de um arquivo. Um objeto de canal de arquivo só pode ser criado chamando o método getChannel() em um objeto de arquivo — não há como criar diretamente um objeto de canal de arquivo.

Além de FileChannel , temos outras implementações de canal:

  • FileChannel — para trabalhar com arquivos

  • DatagramChannel — um canal para trabalhar em uma conexão UDP

  • SocketChannel — um canal para trabalhar em uma conexão TCP

  • ServerSocketChannel contém um SocketChannel e é semelhante ao funcionamento de um servidor da Web

Observação: o FileChannel não pode ser alternado para o modo sem bloqueio. O modo sem bloqueio do Java NIO permite que você solicite dados de leitura de um canal e receba apenas o que está disponível no momento (ou nada se ainda não houver dados disponíveis) . Dito isso, SelectableChannel e suas implementações podem ser colocadas no modo sem bloqueio usando o método connect() .

Seletor

O Java NIO introduziu a capacidade de criar um encadeamento que sabe qual canal está pronto para gravar e ler dados e pode processar esse canal específico. Essa habilidade é implementada usando a classe Selector .

Conectando canais a um seletor


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

Então criamos nosso Selector e o conectamos a um SelectableChannel .

Para ser usado com um seletor, um canal deve estar no modo sem bloqueio. Isso significa que você não pode usar o FileChannel com um seletor, pois o FileChannel não pode ser colocado no modo sem bloqueio. Mas os canais de soquete funcionarão bem.

Aqui vamos mencionar que em nosso exemplo SelectionKey é um conjunto de operações que podem ser executadas em um canal. A tecla de seleção permite saber o estado de um canal.

Tipos de chave de seleção

  • SelectionKey.OP_CONNECT significa um canal que está pronto para se conectar ao servidor.

  • SelectionKey.OP_ACCEPT é um canal que está pronto para aceitar conexões de entrada.

  • SelectionKey.OP_READ significa um canal que está pronto para ler dados.

  • SelectionKey.OP_WRITE significa um canal que está pronto para gravar dados.

Amortecedor

Os dados são lidos em um buffer para processamento posterior. Um desenvolvedor pode mover para frente e para trás no buffer, o que nos dá um pouco mais de flexibilidade ao processar dados. Ao mesmo tempo, precisamos verificar se o buffer contém a quantidade de dados necessária para o processamento correto. Além disso, ao ler dados em um buffer, certifique-se de não destruir os dados existentes que ainda não foram processados.


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

Propriedades básicas de um buffer:

Atributos básicos
capacidade O tamanho do buffer, que é o comprimento da matriz.
posição A posição inicial para trabalhar com dados.
limite O limite operacional. Para operações de leitura, o limite é a quantidade de dados que podem ser lidos, mas para operações de gravação, é a capacidade ou cota disponível para gravação.
marca O índice do valor para o qual o parâmetro de posição será redefinido quando o método reset() for chamado.

Agora vamos falar um pouco sobre o que há de novo no Java NIO.2 .

Caminho

Path representa um caminho no sistema de arquivos. Ele contém o nome de um arquivo e uma lista de diretórios que definem o caminho para ele.


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

Paths é uma classe muito simples com um único método estático: get() . Ele foi criado apenas para obter um objeto Path da string ou URI passada.


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

arquivos

Files é uma classe de utilitário que nos permite obter diretamente o tamanho de um arquivo, copiar arquivos e muito mais.


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

Sistema de arquivo

FileSystem fornece uma interface para o sistema de arquivos. FileSystem funciona como uma fábrica para criar vários objetos (Caminho,PathMatcher,arquivos). Isso nos ajuda a acessar arquivos e outros objetos no sistema de arquivos.


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

Teste de performance

Para este teste, vamos pegar dois arquivos. O primeiro é um pequeno arquivo de texto e o segundo é um vídeo grande.

Vamos criar um arquivo e adicionar algumas palavras e caracteres:

% toque em text.txt

Nosso arquivo ocupa um total de 42 bytes na memória:

Agora vamos escrever o código que copiará nosso arquivo de uma pasta para outra. Vamos testá-lo nos arquivos pequenos e grandes para comparar a velocidade de IO e NIO e NIO.2 .

Código para copiar, escrito usando 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();
        }
    }

E aqui está o código para 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();
        }
    }

Código para 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));
}

Vamos começar com o arquivo pequeno.

O tempo de execução para Java IO foi de 1 milissegundo em média. Executando o teste várias vezes, obtemos resultados de 0 a 2 milissegundos.

Tempo de execução em milissegundos: 1

O tempo de execução do Java NIO é muito maior. O tempo médio é de 11 milissegundos. Os resultados variaram de 9 a 16. Isso ocorre porque o Java IO funciona de maneira diferente do nosso sistema operacional. O IO move e processa arquivos um por um, mas o sistema operacional envia os dados em um grande bloco. O NIO teve um desempenho ruim porque é orientado a buffer, não orientado a fluxo como IO .

Tempo de execução em milissegundos: 12

E também vamos executar nosso teste para Java NIO.2 . O NIO.2 melhorou o gerenciamento de arquivos em comparação com o Java NIO . É por isso que a biblioteca atualizada produz resultados tão diferentes:

Tempo de execução em milissegundos: 3

Agora vamos tentar testar nosso arquivo grande, um vídeo de 521 MB. A tarefa será exatamente a mesma: copiar o arquivo para outra pasta. Olhar!

Resultados para Java IO :

Tempo de execução em milissegundos: 1866

E aqui está o resultado para Java NIO :

Tempo de execução em milissegundos: 205

O Java NIO manipulou o arquivo 9 vezes mais rápido no primeiro teste. Testes repetidos mostraram aproximadamente os mesmos resultados.

E também tentaremos nosso teste em Java NIO.2 :

Tempo de execução em milissegundos: 360

Por que esse resultado? Simplesmente porque não faz muito sentido compararmos o desempenho entre eles, já que servem a propósitos diferentes. O NIO é uma E/S de baixo nível mais abstrata, enquanto o NIO.2 é orientado para o gerenciamento de arquivos.

Resumo

Podemos dizer com segurança que o Java NIO é significativamente mais eficiente ao trabalhar com arquivos graças ao uso dentro de blocos. Outra vantagem é que a biblioteca NIO é dividida em duas partes: uma para trabalhar com arquivos e outra para trabalhar com a rede.

A nova API do Java NIO.2 para trabalhar com arquivos oferece muitos recursos úteis:

  • endereçamento de sistema de arquivos muito mais útil usando Path ,

  • manipulação significativamente aprimorada de arquivos ZIP usando um provedor de sistema de arquivos personalizado,

  • acesso a atributos de arquivo especiais,

  • muitos métodos convenientes, como ler um arquivo inteiro com uma única instrução, copiar um arquivo com uma única instrução, etc.

É tudo sobre arquivos e sistemas de arquivos, e é tudo de alto nível.

A realidade hoje é que o Java NIO é responsável por cerca de 80-90% do trabalho com arquivos, embora a participação do Java IO ainda seja significativa.

💡 PS Estes testes foram executados em um MacBook Pro 14" 16/512. Os resultados do teste podem diferir com base no sistema operacional e nas especificações da estação de trabalho.