¿Por qué Java IO es tan malo?

La API de IO (entrada y salida) es una API de Java que facilita a los desarrolladores el trabajo con flujos. Digamos que recibimos algunos datos (por ejemplo, nombre, segundo nombre, apellido) y necesitamos escribirlos en un archivo: ha llegado el momento de usar java.io.

Estructura de la biblioteca java.io

Pero Java IO tiene sus inconvenientes, así que hablemos de cada uno de ellos por separado:

  1. Bloqueo de acceso para entrada/salida. El problema es que cuando un desarrollador intenta leer o escribir algo en un archivo usando Java IO , bloquea el archivo y bloquea el acceso a él hasta que finaliza el trabajo.
  2. No hay soporte para sistemas de archivos virtuales.
  3. No hay soporte para enlaces.
  4. Montones y montones de excepciones comprobadas.

Trabajar con archivos siempre implica trabajar con excepciones: por ejemplo, intentar crear un nuevo archivo que ya existe arrojará una IOException . En este caso, la aplicación debe continuar ejecutándose y se debe notificar al usuario por qué no se pudo crear el archivo.


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

Aquí vemos que el método createTempFile lanza una IOException cuando no se puede crear el archivo. Esta excepción debe manejarse adecuadamente. Si intentamos llamar a este método fuera de un bloque try-catch , el compilador generará un error y sugerirá dos opciones para solucionarlo: envolver el método en un bloque try-catch o hacer que el método que llama a File.createTempFile arroje una IOException ( para que pueda ser manejado en un nivel superior).

Llegando a Java NIO y cómo se compara con Java IO

Java NIO , o Java Non-Blocking I/O (o, a veces, Java New I/O) está diseñado para operaciones de E/S de alto rendimiento.

Comparemos los métodos Java IO y los que los reemplazan.

Primero, hablemos sobre cómo trabajar con Java IO :

Clase 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 clase FileInputStream es para leer datos de un archivo. Hereda la clase InputStream y por lo tanto implementa todos sus métodos. Si el archivo no se puede abrir, se lanza una FileNotFoundException .

clase 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 clase FileOutputStream para escribir bytes en un archivo. Deriva de la clase OutputStream .

Clases de lector y escritor

La clase FileReader nos permite leer datos de caracteres de secuencias y la clase FileWriter se usa para escribir secuencias de caracteres. El siguiente código muestra cómo escribir y leer desde un archivo:


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

Ahora hablemos de Java NIO :

Canal

A diferencia de los flujos utilizados en Java IO , Channel es una interfaz bidireccional, es decir, puede leer y escribir. Un canal Java NIO admite el flujo de datos asíncrono en los modos de bloqueo y no bloqueo.


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

Aquí usamos un FileChannel . Usamos un canal de archivo para leer datos de un archivo. Un objeto de canal de archivo solo se puede crear llamando al método getChannel() en un objeto de archivo; no hay forma de crear directamente un objeto de canal de archivo.

Además de FileChannel , tenemos otras implementaciones de canales:

  • FileChannel : para trabajar con archivos

  • DatagramChannel : un canal para trabajar a través de una conexión UDP

  • SocketChannel : un canal para trabajar a través de una conexión TCP

  • ServerSocketChannel contiene un SocketChannel y es similar a cómo funciona un servidor web

Tenga en cuenta: FileChannel no se puede cambiar al modo sin bloqueo. El modo sin bloqueo de Java NIO le permite solicitar datos de lectura de un canal y recibir solo lo que está disponible actualmente (o nada en absoluto si aún no hay datos disponibles) . Dicho esto, SelectableChannel y sus implementaciones se pueden poner en modo sin bloqueo usando el método connect() .

Selector

Java NIO introdujo la capacidad de crear un hilo que sabe qué canal está listo para escribir y leer datos y puede procesar ese canal en particular. Esta habilidad se implementa usando la clase Selector .

Conexión de canales a un selector


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

Así que creamos nuestro Selector y lo conectamos a un SelectableChannel .

Para usarse con un selector, un canal debe estar en modo sin bloqueo. Esto significa que no puede usar FileChannel con un selector, ya que FileChannel no se puede poner en modo sin bloqueo. Pero los canales de socket funcionarán bien.

Aquí mencionemos que en nuestro ejemplo, SelectionKey es un conjunto de operaciones que se pueden realizar en un canal. La tecla de selección nos permite conocer el estado de un canal.

Tipos de clave de selección

  • SelectionKey.OP_CONNECT significa un canal que está listo para conectarse al servidor.

  • SelectionKey.OP_ACCEPT es un canal que está listo para aceptar conexiones entrantes.

  • SelectionKey.OP_READ significa un canal que está listo para leer datos.

  • SelectionKey.OP_WRITE significa un canal que está listo para escribir datos.

Buffer

Los datos se leen en un búfer para su posterior procesamiento. Un desarrollador puede avanzar y retroceder en el búfer, lo que nos brinda un poco más de flexibilidad al procesar datos. Al mismo tiempo, debemos verificar si el búfer contiene la cantidad de datos necesarios para el procesamiento correcto. Además, al leer datos en un búfer, asegúrese de no destruir los datos existentes que aún no se han procesado.


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

Propiedades básicas de un búfer:

Atributos básicos
capacidad El tamaño del búfer, que es la longitud de la matriz.
posición La posición inicial para trabajar con datos.
límite El límite operativo. Para operaciones de lectura, el límite es la cantidad de datos que se pueden leer, pero para operaciones de escritura, es la capacidad o cuota disponible para escritura.
marca El índice del valor al que se restablecerá el parámetro de posición cuando se llame al método reset() .

Ahora hablemos un poco sobre las novedades de Java NIO.2 .

Camino

Path representa una ruta en el sistema de archivos. Contiene el nombre de un archivo y una lista de directorios que definen la ruta al mismo.


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

Paths es una clase muy simple con un único método estático: get() . Fue creado únicamente para obtener un objeto Path de la cadena o URI pasada.


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

archivos

Archivos es una clase de utilidad que nos permite obtener directamente el tamaño de un archivo, copiar archivos y más.


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

sistema de archivos

FileSystem proporciona una interfaz para el sistema de archivos. FileSystem funciona como una fábrica para crear varios objetos (Camino,PathMatcher,archivos). Nos ayuda a acceder a archivos y otros objetos en el sistema de archivos.


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

Prueba de rendimiento

Para esta prueba, tomemos dos archivos. El primero es un archivo de texto pequeño y el segundo es un video grande.

Crearemos un archivo y agregaremos algunas palabras y caracteres:

% toque texto.txt

Nuestro archivo ocupa un total de 42 bytes en memoria:

Ahora escribamos código que copiará nuestro archivo de una carpeta a otra. Probémoslo en archivos pequeños y grandes para comparar la velocidad de IO y NIO y 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();
        }
    }

Y aquí está el 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));
}

Comencemos con el archivo pequeño.

El tiempo de ejecución de Java IO fue de 1 milisegundo en promedio. Al ejecutar la prueba varias veces, obtenemos resultados de 0 a 2 milisegundos.

Tiempo de ejecución en milisegundos: 1

El tiempo de ejecución de Java NIO es mucho más largo. El tiempo promedio es de 11 milisegundos. Los resultados oscilaron entre 9 y 16. Esto se debe a que Java IO funciona de manera diferente a nuestro sistema operativo. IO mueve y procesa los archivos uno por uno, pero el sistema operativo envía los datos en una gran porción. NIO funcionó mal porque está orientado al búfer, no al flujo como IO .

Tiempo de ejecución en milisegundos: 12

Y también ejecutemos nuestra prueba para Java NIO.2 . NIO.2 ha mejorado la gestión de archivos en comparación con Java NIO . Esta es la razón por la cual la biblioteca actualizada produce resultados tan diferentes:

Tiempo de ejecución en milisegundos: 3

Ahora intentemos probar nuestro archivo grande, un video de 521 MB. La tarea será exactamente la misma: copiar el archivo a otra carpeta. ¡Mirar!

Resultados para Java IO :

Tiempo de ejecución en milisegundos: 1866

Y aquí está el resultado para Java NIO :

Tiempo de ejecución en milisegundos: 205

Java NIO manejó el archivo 9 veces más rápido en la primera prueba. Las pruebas repetidas mostraron aproximadamente los mismos resultados.

Y también probaremos nuestra prueba en Java NIO.2 :

Tiempo de ejecución en milisegundos: 360

¿Por qué este resultado? Simplemente porque no tiene mucho sentido que comparemos el rendimiento entre ellos, ya que sirven para diferentes propósitos. NIO es una E/S de bajo nivel más abstracta, mientras que NIO.2 está orientado a la gestión de archivos.

Resumen

Podemos decir con seguridad que Java NIO es significativamente más eficiente cuando se trabaja con archivos gracias al uso dentro de los bloques. Otra ventaja es que la biblioteca NIO se divide en dos partes: una para trabajar con archivos y otra para trabajar con la red.

La nueva API de Java NIO.2 para trabajar con archivos ofrece muchas características útiles:

  • mucho más útil el direccionamiento del sistema de archivos usando Path ,

  • manejo significativamente mejorado de archivos ZIP utilizando un proveedor de sistema de archivos personalizado,

  • acceso a atributos de archivos especiales,

  • muchos métodos convenientes, como leer un archivo completo con una sola declaración, copiar un archivo con una sola declaración, etc.

Se trata de archivos y sistemas de archivos, y todo es de un nivel bastante alto.

La realidad actual es que Java NIO representa aproximadamente el 80-90 % del trabajo con archivos, aunque la participación de Java IO sigue siendo significativa.

💡 PD Estas pruebas se realizaron en una MacBook Pro 14" 16/512. Los resultados de las pruebas pueden diferir según el sistema operativo y las especificaciones de la estación de trabajo.