Чим такий поганий 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 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. 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 мілісекунд.

Час виконання у мілісекундах: 11

Час виконання за допомогою 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. Результати тесту можуть відрізнятись від операційної системи та параметрів робочої машини програміста.