Dlaczego Java IO jest taka zła?

Interfejs API IO (Input & Output) to interfejs API języka Java, który ułatwia programistom pracę ze strumieniami. Powiedzmy, że otrzymaliśmy jakieś dane (na przykład nazwisko, imię i patronimię) i musimy je zapisać do pliku - w tym momencie czas użyć java.io .

Struktura biblioteki java.io

Ale Java IO ma swoje wady, więc porozmawiajmy o każdym z nich w kolejności:

  1. Blokowanie dostępu do wprowadzania/wyprowadzania danych. Problem polega na tym, że gdy programista próbuje odczytać lub zapisać plik za pomocą Java IO , blokuje plik i dostęp do niego do końca swojego zadania.
  2. Brak obsługi wirtualnych systemów plików.
  3. Brak obsługi linków.
  4. Bardzo duża liczba sprawdzonych wyjątków.

Praca z plikami zawsze pociąga za sobą pracę z wyjątkami: na przykład próba utworzenia nowego pliku, który już istnieje, spowoduje zgłoszenie wyjątku IOException . W takim przypadku aplikacja powinna kontynuować, a użytkownik powinien zostać powiadomiony, dlaczego plik nie mógł zostać utworzony.

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

Tutaj widzimy, że metoda createTempFile zgłasza wyjątek IOException , gdy nie można utworzyć pliku. Ten wyjątek należy odpowiednio potraktować. Jeśli spróbujemy wywołać tę metodę poza blokiem try-catch , kompilator zgłosi błąd i zaoferuje nam dwie opcje naprawy: otocz metodę blokiem try-catch lub utwórz metodę, wewnątrz której wywoływany jest plik File.createTempFile rzuć wyjątek IOException (aby przekazać go do najwyższego poziomu w celu przetworzenia).

Jadąc do Java NIO i porównanie z Java IO

Java NIO , czyli Java Non-blocking I/O (czasami Java New I/O, „new I/O”) ma na celu realizację wysokowydajnych operacji I/O.

Spróbujmy porównać metody Java IO i te, które przyszły je zastąpić.

Najpierw porozmawiajmy o pracy z Java IO :

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

Klasa FileInputStream jest przeznaczona do odczytu danych z pliku. Dziedziczy z klasy InputStream i dlatego implementuje wszystkie swoje metody. Jeśli nie można otworzyć pliku, zgłaszany jest wyjątek FileNotFoundException .

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

Klasa FileOutputStream służy do zapisywania bajtów w pliku. Wywodzi się z klasy OutputStream .

Klasy czytelnika i pisarza

Klasa FileReader pozwala nam odczytywać dane znakowe ze strumieni, natomiast klasa FileWriter służy do zapisywania strumieni znaków. Poniżej przedstawiono implementację zapisu i odczytu z pliku:

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

Porozmawiajmy teraz o Javie NIO :

Kanał

W przeciwieństwie do strumieni, które są używane w Javie IO , kanał jest dwukierunkowy, to znaczy może odczytywać i zapisywać. Kanał Java NIO obsługuje asynchroniczny przepływ danych zarówno w trybie blokującym, jak i nieblokującym.

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

Tutaj zaimplementowaliśmy FileChannel . Aby odczytać dane z pliku, używamy potoku plików. Obiekt kanału plików można utworzyć tylko przez wywołanie metody getChannel() na obiekcie pliku, ponieważ nie możemy bezpośrednio utworzyć obiektu kanału plików.

Oprócz FileChannel mamy inne implementacje kanałów:

  • FileChannel - praca z plikami

  • DatagramChannel - kanał do pracy na połączeniu UDP

  • SocketChannel - kanał do pracy przez połączenie TCP

  • ServerSocketChannel zawiera SocketChannel i jest podobny do działania serwera WWW

Uwaga: FileChannel nie może zostać przełączony w tryb nieblokujący. Tryb nieblokujący Java NIO pozwala żądać odczytanych danych z kanału i odbierać tylko to, co jest aktualnie dostępne, lub wcale, jeśli nie ma jeszcze dostępnych danych. Jednocześnie SelectableChannel i jego implementacje można ustawić w trybie nieblokującym za pomocą metody connect() .

selektor

Java NIO wprowadziła możliwość tworzenia wątku, który wie, który kanał jest gotowy do zapisu i odczytu danych oraz może przetwarzać ten konkretny kanał. Ta funkcja jest zaimplementowana przy użyciu klasy Selector .

Wykonanie połączenia kanałów i selektora

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

W ten sposób tworzymy nasz Selektor i wiążemy go z SelectableChannel .

Aby można było używać z selektorem, kanał musi być w trybie nieblokującym. Oznacza to, że nie można użyć kanału FileChannel z selektorem, ponieważ kanału FileChannel nie można przełączyć w tryb nieblokujący. Kanały gniazdowe będą działać poprawnie.

Tutaj możemy zauważyć, że w naszym przykładzie SelectionKey to zestaw operacji, które można wykonać na kanale. Status kanału możemy sprawdzić klawiszem wyboru.

Typy kluczy wyboru:

  • SelectionKey.OP_CONNECT to kanał gotowy do połączenia z serwerem.

  • SelectionKey.OP_ACCEPT - kanał gotowy do przyjmowania połączeń przychodzących.

  • SelectionKey.OP_READ to kanał gotowy do odczytu danych.

  • SelectionKey.OP_WRITE to kanał gotowy do zapisu danych.

Bufor

Dane są wczytywane do bufora w celu dalszego przetwarzania. Deweloper może poruszać się tam iz powrotem po buforze, co daje nam nieco większą elastyczność w przetwarzaniu danych. Jednocześnie musimy sprawdzić, czy bufor zawiera ilość danych niezbędną do poprawnego przetworzenia. Nie zapomnij również upewnić się, że podczas wczytywania danych do bufora nie zniszczysz danych, które nie zostały tam jeszcze przetworzone.

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

Główne właściwości bufora:

Główne atrybuty
pojemność Rozmiar bufora, który jest długością tablicy.
pozycja Pozycja wyjściowa do pracy z danymi.
limit limit operacyjny. W przypadku operacji odczytu limitem jest ilość danych, które można przenieść do trybu online, a w przypadku operacji zapisu — podany poniżej limit pojemności lub zapisywalny limit.
ocena Indeks wartości, do której zostanie zresetowany parametr position po wywołaniu metody reset() .

Porozmawiajmy teraz trochę o nowościach w Javie NIO2 .

Ścieżka

Ścieżka to ścieżka w systemie plików. Zawiera nazwę pliku i listę katalogów, które określają ścieżkę do niego.

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

Paths to bardzo prosta klasa z pojedynczą statyczną metodą get() . Został stworzony wyłącznie w celu uzyskania obiektu typu Path z przekazanego ciągu znaków lub identyfikatora URI .

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

Akta

Files to klasa narzędzi, za pomocą której możemy bezpośrednio uzyskać rozmiar pliku, skopiować go i nie tylko.

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

System plików

FileSystem zapewnia interfejs do systemu plików. System plików działa jak fabryka do tworzenia różnych obiektów (Ścieżka,PathMatcher,Akta). Ten obiekt pomaga uzyskać dostęp do plików i innych obiektów w systemie plików.

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

Test wydajności

Do tego testu weźmy dwa pliki. Pierwszy to mały plik tekstowy, a drugi to duży plik wideo.

Utwórz plik i dodaj do niego kilka słów i symboli:

%touchtext.txt

Nasz plik zajmuje łącznie 42 bajty w pamięci:

Teraz napiszmy kod, który skopiuje nasz plik z jednego folderu do drugiego. Sprawdźmy jego działanie na małych i dużych plikach, a tym samym porównajmy szybkość IO , NIO i NIO2 .

Skopiuj kod napisany w Javie 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();
        }
    }

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

Kod dla Javy 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("Execution time in milliseconds: " + (currentMills - startMills));
}

Zacznijmy od małego pliku.

Czas wykonania z Java IO wynosił średnio 1 milisekundę. Przeprowadzając test kilka razy, otrzymujemy wynik od 0 do 2 milisekund.

Czas wykonania w milisekundach: 1

Czas wykonania z Java NIO jest znacznie dłuższy. Średni czas to 11 milisekund. Wyniki były od 9 do 16. Wynika to z faktu, że Java IO nie działa tak, jak nasz system operacyjny. IO przenosi i przetwarza pliki jeden po drugim, podczas gdy system operacyjny wysyła dane w jednej dużej formie. A NIO działało słabo ze względu na fakt, że jest zorientowane na bufor, a nie zorientowane na strumień, jak IO .

Czas wykonania w milisekundach: 12

I po prostu uruchom nasz test dla Java NIO2 . NIO2 ma ulepszone zarządzanie plikami w porównaniu do Java NIO . Z tego powodu wyniki zaktualizowanej biblioteki są tak różne:

Czas wykonania w milisekundach: 3

Teraz spróbujmy przetestować nasz duży plik, 521 MB wideo. Zadanie będzie dokładnie takie samo: skopiuj do innego folderu. Patrzeć!

Wyniki z Java IO :

Czas wykonania w milisekundach: 1866

A oto wynik Java NIO :

Czas wykonania w milisekundach: 205

Java NIO poradziła sobie z plikiem 9 razy szybciej w pierwszym teście. Wielokrotne testy wykazały w przybliżeniu te same wyniki.

Wypróbujmy również nasz test Java NIO2 :

Czas wykonania w milisekundach: 360

Dlaczego taki wynik? Po prostu dlatego, że porównywanie wydajności między nimi nie ma większego sensu, ponieważ służą one różnym celom. NIO jest bardziej abstrakcyjnym I/O danych niskiego poziomu, podczas gdy NIO2 koncentruje się na zarządzaniu plikami.

Wyniki

Śmiało można powiedzieć, że Java NIO znacznie zwiększa efektywność pracy z plikami poprzez zastosowanie bloków. Dodatkowym plusem jest to, że biblioteka NIO jest podzielona na dwie części: jedna do pracy z plikami, druga do pracy w sieci.

Nowe API plików używane w Javie NIO2 oferuje wiele przydatnych funkcji:

  • znacznie bardziej przydatne adresowanie systemu plików za pomocą Path ,

  • znacznie poprawiona obsługa plików ZIP przy użyciu niestandardowego dostawcy systemu plików,

  • dostęp do specjalnych atrybutów plików,

  • wiele wygodnych metod, takich jak odczytanie całego pliku jednym poleceniem, skopiowanie pliku jednym poleceniem itp.

Chodzi o plik i system plików, a wszystko jest na dość wysokim poziomie.

We współczesnych realiach Java NIO zajmuje około 80-90% pracy z plikami, chociaż udział Java IO jest nadal znaczący.

💡 PS Testowane na MacBooku Pro 14' 16/512. Wyniki testów mogą odbiegać od systemu operacyjnego i parametrów maszyny roboczej programisty.