Чем так плох Java IO?

IO (Input & Output) API — это Java API, которое облегчает разработчикам работу с потоками. Скажем, мы получили какие-то данные (например, фамилия, имя и отчество) и нам нужно записать их в файл — в этот момент и приходит время использовать java.io.

Структура библиотеки java.io

Но у Java IO есть свои недостатки, так что давай поговорим по порядку о каждом из них:

  1. Блокирующий доступ для ввода/вывода данных. Проблема состоит в том, что когда разработчик пытается прочитать файл или записать что-то в него, используя Java IO, он блокирует файл и доступ к нему до момента окончания выполнения своей задачи.
  2. Отсутствует поддержка виртуальных файловых систем.
  3. Нет поддержки ссылок.
  4. Очень большое количество checked исключений.

Работа с файлами всегда несет за собой работу с исключениями: например, попытка создать новый файл, который уже существует, вызовет IOException. В данном случае работа приложения должна быть продолжена и пользователь должен получить уведомление о том, по какой причине файл не может быть создан.


try {
	File.createTempFile("prefix", "");
} catch (IOException e) {
	// Handle 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 {
...
}

Здесь мы видим, что метод createTempFile выбрасывает IOException, когда файл не может быть создан. Это исключение должно быть обработано соответственно. Если попытаться вызвать этот метод вне блока try-catch, то компилятор выдаст ошибку и предложит нам два варианта исправления: окружить метод блоком try-catch или сделать так, чтобы метод, внутри которого вызывается File.createTempFile, выбрасывал исключение IOException (чтобы передать его на верхний уровень для обработки).

Приход к Java NIO и сравнение с Java IO

Java NIO, или Java Non-blocking I/O (иногда — Java New I/O, “новый ввод-вывод”) предназначена для реализации высокопроизводительных операций ввода-вывода.

Давай попробуем сравнить методы Java IO и те, что пришли им на замену.

Сначала поговорим о работе с Java IO:

Класс InputStream


try(FileInputStream fin = new FileInputStream("C:/javarush/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());
}

Класс FileInputStream предназначен для считывания данных из файла. Он является наследником класса InputStream и поэтому реализует все его методы. Если файл не может быть открыт, то генерируется исключение FileNotFoundException.

Класс OutputStream


String text = "Hello world!"; // строка для записи
try(FileOutputStream fos = new FileOutputStream("C:/javarush/file.txt")){
    // переводим нашу строку в байты
    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());
}

Класс FileOutputStream предназначен для записи байтов в файл. Он является производным от класса OutputStream.

Классы Reader и Writer

Класс FileReader позволяет нам читать символьные данные из потоков, а класс FileWriter используется для записи потоков символов. Реализация записи и чтения из файла приведена ниже:


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

        // Создание объекта FileWriter
        try (FileWriter writer = new FileWriter(fileName)) {

            // Запись содержимого в файл
            writer.write("Это простой пример,\n в котором мы осуществляем\n с помощью языка Java\n запись в файл\n и чтение из файла\n");
            writer.flush();
        } catch (IOException e) {
            e.printStackTrace();
        }

        // Создание объекта FileReader
        try (FileReader fr = new FileReader(fileName)) {
            char[] a = new char[200];// Количество символов, которое будем считывать
            fr.read(a);   // Чтение содержимого в массив
            for (char c : a) {
                System.out.print(c); // Вывод символов один за другими
            }
        } catch (IOException e) {
            e.printStackTrace();
        }

Теперь поговорим о Java NIO:

Channel

В отличие от потоков, которые используются в Java IO, Channel является двусторонним, то есть может и считывать, и записывать. Канал Java NIO поддерживает асинхронный поток данных как в режиме блокировки, так и в режиме без блокировки.


RandomAccessFile aFile = new RandomAccessFile("C:/javarush/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();

Здесь мы реализовали FileChannel. Для чтения данных из файла мы используем файловый канал. Объект файлового канала может быть создан только вызовом метода getChannel() для файлового объекта, поскольку мы не можем напрямую создать объект файлового канала.

Кроме FileChannel у нас есть и другие реализации каналов:

  • FileChannel — работа с файлами

  • DatagramChannel — канал для работы по UDP-соединению

  • SocketChannel — канал для работы по TCP-соединению

  • ServerSocketChannel содержит в себе SocketChannel и схож с принципом работы веб-сервера

Обрати внимание: FileChannel нельзя переключить в неблокирующий режим. Неблокирующий режим Java NIO позволяет запрашивать считанные данные из канала (channel) и получать только то, что доступно на данный момент, или вообще ничего, если доступных данных пока нет. В это же время SelectableChannel и его реализации могут устанавливаться в неблокирующем режиме с помощью метода connect().

Selector

В Java NIO появилась возможность создать поток, который будет знать, какой канал готов для записи и чтения данных и может обрабатывать этот конкретный канал. Возможность эта реализуется с помощью класса Selector.

Реализация связи каналов и селектора


Selector selector = Selector.open();
channel.configureBlocking(false); // неблокирующий режим
SelectionKey key = channel.register(selector, SelectionKey.OP_READ);

Таким образом мы создаем наш Selector и связываем его с SelectableChannel.

Для использования с селектором канал должен находиться в неблокирующем режиме. Это значит, что вы не можете использовать FileChannel с селектором, поскольку FileChannel нельзя переключить в неблокирующий режим. Сокетные каналы будут работать нормально.

Здесь мы можем отметить, что в нашем примере SelectionKey — это набор операций, которые можно выполнить с каналом. Мы можем узнать состояние канала с помощью клавиши выбора.

Типы SelectionKey:

  • SelectionKey.OP_CONNECT — канал, который готов к подключению к серверу.

  • SelectionKey.OP_ACCEPT — канал, который готов принимать входящие соединения.

  • SelectionKey.OP_READ — канал, который готов к чтению данных.

  • SelectionKey.OP_WRITE — канал, который готов к записи данных.

Buffer

Данные считываются в буфер для последующей обработки. Разработчик может двигаться по буферу вперед и назад, что дает нам немного больше гибкости при обработке данных. В то же время нам нужно проверять, содержит ли буфер необходимый для корректной обработки объем данных. Также не забывай следить, чтобы при чтении данных в буфер не уничтожить еще не обработанные данные, находящиеся там.


ByteBuffer buf = ByteBuffer.allocate (2048); 
int bytesRead = channel.read(buf);
buf.flip(); // меняем режим на чтение
while (buf.hasRemaining()) { 
	byte data = buf.get(); // есть методы для примитивов 
}

buf.clear(); // очистили и можно переиспользовать

Основные свойства буфера:

Основные атрибуты
capacity Размер буфера, который является длиной массива.
position Начальная позиция для работы с данными.
limit Операционный лимит. Для операций чтения предел — это объем данных, который можно поместить в оперативный режим, а для операций записи — предел емкости или доступная для записи квота, указанная ниже.
mark Индекс значения, до которого будет сброшен параметр position при вызове метода reset().

Теперь давай немного поговорим о том, что нового появилось в Java NIO2.

Path

Path представляет из себя путь в файловой системе. Он содержит имя файла и список каталогов, определяющих путь к нему.


Path relative = Paths.get("Main.java");
System.out.println("Файл: " + relative);
//получение файловой системы
System.out.println(relative.getFileSystem());

Paths — это совсем простой класс с единственным статическим методом get(). Его создали исключительно для того, чтобы из переданной строки или URI получить объект типа Path.


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

Files

Files — это утилитный класс, с помощью которого мы можем напрямую получать размер файла, копировать их, и не только.


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

FileSystem

FileSystem предоставляет интерфейс к файловой системе. Файловая система работает как фабрика для создания различных объектов (Path, PathMatcher, Files). Этот объект помогает получить доступ к файлам и другим объектам в файловой системе.


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

Тест производительности

Для этого теста давай возьмем два файла. Первый — маленький файл с текстом, а второй — большой видеоролик.

Создаем файл и добавляем в него немного слов и символов:

% touch text.txt

Наш файл по итогу занимает в памяти 42 байта:

Теперь напишем код, который будет копировать наш файл из одной папки в другую. Проверим его работу на маленьком и большом файлах, и тем самым сравним скорость работы IO, NIO и NIO2.

Код для копирования, написанный на 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("Время выполнения в миллисекундах: " + (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;
            // Читаем данные в байтовый массив, а затем выводим в OutStream
            while((length = inputStream.read(buffer)) > 0){
                outputStream.write(buffer, 0, length);
            }
        } catch (IOException e) {
            e.printStackTrace();
        }
    }

И код для 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");
        // копия nio
        copyFileByChannel(src, dst);
        currentMills = System.currentTimeMillis();
        System.out.println("Время выполнения в миллисекундах: " + (currentMills - startMills));
    }

    public static void copyFileByChannel(File src, File dst){
        // 1. Получаем FileChannel исходного файла и целевого файла
        try(FileChannel srcFileChannel  = new FileInputStream(src).getChannel();
            FileChannel dstFileChannel = new FileOutputStream(dst).getChannel()){
            // 2. Размер текущего FileChannel
            long count = srcFileChannel.size();
            while(count > 0){
                /**=============================================================
                 * 3. Записать байты из FileChannel исходного файла в целевой FileChannel
                 * 1. srcFileChannel.position (): начальная позиция в исходном файле не может быть отрицательной
                 * 2. count: максимальное количество переданных байтов, не может быть отрицательным
                 * 3. dstFileChannel: целевой файл
                 *==============================================================*/
                long transferred = srcFileChannel.transferTo(srcFileChannel.position(),
                        count, dstFileChannel);
                // 4. После завершения переноса измените положение исходного файла на новое место
                srcFileChannel.position(srcFileChannel.position() + transferred);
                // 5. Рассчитаем, сколько байтов осталось перенести
                count -= transferred;
            }

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

Код для Java NIO2:


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("Время выполнения в миллисекундах: " + (currentMills - startMills));
}

Начнем с маленького файла.

Время выполнения с помощью Java IO в среднем было 1 миллисекунду. Запуская тест несколько раз, получаем резульат от 0 до 2 миллисекунд.

Время выполнения в миллисекундах: 1

Время выполнения с помощью Java NIO гораздо больше. Среднее время — 11 миллисекунд. Результаты были от 9 до 16. Это связанно с тем, что Java IO работает не так, как наша операционная система. IO перемещает и обрабатывает файлы один за другим, в то время как операционная система отправляет данные в одном большом виде. А NIO показал плохие результаты из-за того, что он ориентирован на буфер, а не на поток, как IO.

Время выполнения в миллисекундах: 12

И так же запустим наш тест для Java NIO2. NIO2 имеет улучшенное управление с файлами по сравнению с Java NIO. Из-за этого результаты обновленной библиотеки так отличаются:

Время выполнения в миллисекундах: 3

А теперь давай попробуем протестировать наш большой файл, видео на 521 МБ. Задача будет точно такой же: скопировать в другую папку. Смотрим!

Результаты с Java IO:

Время выполнения в миллисекундах: 1866

А вот результат Java NIO:

Время выполнения в миллисекундах: 205

Java NIO справился с файлом в 9 раз быстрее при первом тесте. Повторные тесты показывали примерно такие же результаты.

А так же попробуем наш тест на Java NIO2:

Время выполнения в миллисекундах: 360

Почему же такой результат? Просто потому что у нас нет особого смысла сравнивать производительность между ними, так как они служат разным целям. NIO представляет собой более абстрактный низкоуровневый ввод-вывод данных, а NIO2 ориентирован на управление файлами.

Итоги

Можем смело заявить, что Java NIO существенно повышает эффективность работы с файлами за счет использования внутри блоков. Еще один плюс состоит в том, что библиотека NIO разбита на две части: одна для работы с файлами, вторая — для работы в сети.

Новый API, который используется в Java NIO2 для работы с файлами, предлагает множество полезных функций:

  • гораздо более полезную адресацию файловой системы с помощью Path,

  • значительно улучшенную работу с ZIP-файлами с использованием пользовательского поставщика файловой системы,

  • доступ к специальным атрибутам файла,

  • множество удобных методов, например, чтение всего файла с помощью одной команды, копирование файла с помощью одной команда и т. д.

Все это связано с файлом и файловой системой, и все довольно высокого уровня.

В современных реалиях Java NIO занимает около 80-90% работы с файлами, хотя доля Java IO тоже еще существенна.

💡 P. S. Тесты проводились на MacBook Pro 14’ 16/512. Результаты теста могут отличатся от операционной системы и параметров рабочей машины программиста.