Jak radzić sobie z dużymi plikami w Javie ?

Jak radzić sobie z dużymi plikami w Javie ?

W tym artykule zajmiemy się tematem radzenia sobie z dużymi plikami w Javie. Na pierwszy rzut oka temat może wydawać się skomplikowany, ale wystarczy znać kilka podstawowych zagadnień związanych z zapisem i odczytem plików, żeby przekonać się, że nie jest to takie trudne.

Jak zdefiniować czym jest duży plik?

Żeby rozpocząć rozważania na temat dużych plików, musimy ustalić jaką miarę przyjąć, powyżej której plik można uznać za „duży”. Trudno tu znaleźć jakąś sensowną definicję, ponieważ gdy przetwarzamy dużą ilość danych zawartych w plikach, to nie tylko wielkość pliku jest ważna, ale także to, ile tych plików jest. Dla jednych dużo to jest 1000 plików wielkości 5 MB (= 5GB). Dla innych jeden plik, który ma powyżej 1 GB.

Chodzi tu bardziej o skalę danych do przetworzenia, niż o samą wielkość plików i ich ilość.

Dla uproszczenia przyjmijmy, że każdy plik, którego nie da się otworzyć w „zwykłym” notatniku, to duży plik. Powiedzmy, że plik o wielkości ok. 1GB jest tutaj dobrym przykładem.

Jak edytować duży plik tekstowy ?

Otworzenie gigabajtowego pliku w zwykłym notatniku nie jest łatwym zadaniem, a właściwie jest niemożliwe. Jeśli spróbujesz otworzyć taki plik w notatniku windowsowym (Notepad), to otrzymasz następujący błąd:

A jeśli skorzystasz z bardziej zaawansowanego notatnika np. Notepad++, będzie podobnie:

Nawet jeśli twoim systemem operacyjnym jest linux i skorzystasz z jakiegoś notatnika linux’owego np. Gedit, to uzyskasz podobny efekt. Dlaczego tak się dzieje? Edytory te działają w taki sposób, ze próbują wczytać cały plik do pamięci, co w przypadku małych plików nie jest problemem, a nawet przyspiesza ich edycję. Natomiast duże pliki nie mieszczą się po prostu w pamięci, jaką mamy do dyspozycji w tych edytorach.

Co zrobić zatem w sytuacji, gdy mamy duży plik i musimy pozmieniać w nim niektóre rzeczy? Z pomocą przychodzą nam takie narzędzia jak np. vim. Jest to edytor, który działa z wiersza poleceń, dzięki czemu jest też on trochę wydajniejszy i lepiej radzi sobie z dużymi plikami. Jednak nadal będziesz mieć ten sam problem (wczytywanie całego pliku do pamięci). Jeżeli twój plik jest większy od pamięci RAM jaką posiada twój komputer to nie da się go odczytać w ten sposób.

Less is more, more or less

Kolejnym narzędziem, które może nam pomóc jest linuksowe polecenie less. Jest to narzędzie, które służy do przeglądania plików i rozmiar nie ma tutaj znaczenia, ponieważ less wczytuje tylko fragment pliku. Daje także całkiem duże możliwości wyszukiwania, ale sam jako taki nie pozwala na edycję dokumentów.

Stream Editor (sed)

Kolejnym narzędziem, które może nam pomóc jest polecenie linux’owe sed. Służy ono do edycji plików. Jest to narzędzie, które traktuje plik jako strumień, dzięki czemu jest w stanie przetworzyć bardzo duże pliki przy wykorzystaniu niewielkiej ilości pamięci.

Pozostałe przydatne narzędzia

Do tego mamy jeszcze dwa przydatne narzędzia linux’owe, które mogą pomóc przy pracy z dużymi plikami:

  • wc (word count) – narzędzie, które służy do zliczania liczby słów lub linii np. wc -l
  • grep – zaawansowane narzędzie do wyszukiwania w pliku/plikach

Wszystkie powyższe narzędzia: vim, less, wc, grep, sed, są dostępne na platformach Linux, Mac Os oraz Windows (na Windows można je doinstalować w MinGW, który jest też domyślnie instalowany z gitem lub poprzez Cygwin).

W rzeczywistości bardzo rzadko zachodzi potrzeba ręcznego edytowania tak dużych plików. Zwykle obrabia się je za pomocą skryptów lub różnego rodzaju programów. Ja w tym artykule pokażę kilka prostych przykładów jak robić to w Javie.

Jak utworzyć duży plik na potrzeby testów ?

Zaczniemy od utworzenia pliku testowego o odpowiednim rozmiarze czyli ok. 1GB. Użyję do tego FileWriter i BufferedWriter tak, żeby zapis pliku był jak najszybszy:

private static void writer() throws IOException {
    List<String> strings = Arrays.asList("Pierwsza linia", "Druga linia", "Trzecia linia"); // 1

    try (BufferedWriter bw = new BufferedWriter(new FileWriter("bigFile.txt"))) { // 2
        for (long i = 0; i < 30_000_000; i++) { // 3
            bw.write(strings.get(0)); // 4
            bw.newLine();
            bw.write(strings.get(1));
            bw.newLine();
            bw.write(strings.get(2));
            bw.newLine();
        }
    }
}
  1. Inicjujemy tablica String'ów, które będziemy wstawiali do pliku.
  2. Tworzymy BufferedWriter.
  3. Tworzymy pętlę, która wykona 30 mln obrotów, gdzie każdy zapisze 3 linijki.
  4. Zapisujemy linie pobrane z tablicy, oddzielając je znakiem końca wiersza.

W ten sposób przygotowana metoda, pozwoli nam zapisać plik o wielkości ok 1,3GB (na moim komputerze trwa to ok. 13s). Możesz, w zależności od potrzeb, wygenerować większy lub mniejszy plik, manipulując wartością w pętli.

Zapis
13263 ms // czas podany w milisekundach

Jak odczytać duży plik w Javie ?

Mamy kilka możliwości, aby odczytać plik w Javie. Każda z tych metod jest trochę inna i każda jest bardziej lub mniej wydajna. Wybór metody odczytu powinien zależeć od tego, jakie masz potrzeby w danej chwili. Ja tylko testuje te metody, więc skupię się na szybkości ich działania.

FileReader i odczyt znak po znaku

Najprostszy odczyt, który tu prezentuję, oparty tylko o FileReader. W przykładzie tym korzystam z najprostszej metody read(), która odczytuje plik bajt po bajcie. FileReader zawiera także inne metody read, które mogą być nawet bardziej wydajne jeśli wykorzystamy bufor. Warto też się z nimi zapoznać.

private static void readFile() throws IOException {
    try (FileReader reader = new FileReader("bigFile.txt")) { // 1
        int character = 0;
        long counter = 0;
        while ((character = reader.read()) != -1) { // 2
            counter++; // 3
        }
        System.out.println(counter); // 4
    }
}
  1. Tworzymy FileReader.
  2. Odczytujemy bajty za pomoc metody read.
  3. Zliczamy bajty.
  4. Drukujemy ilość zliczonych bajtów.

Wynik:

1320000000 // counter
Odczyt byte
31896 ms

Scanner i odczyt linia po linii

Do odczytu pliku możemy także użyć klasy Scanner, która ma całkiem zaawansowane możliwości odnoście dzielenia tekstu na tokeny. Jednak odczytywanie przez nią pliku może być znacznie wolniejsze.

private static void readScanner() throws FileNotFoundException {
    Scanner scanner = new Scanner(new File("bigFile.txt")); // 1
    long counter = 0;
    while (scanner.hasNextLine()) { // 2
        scanner.nextLine(); // 3
        counter++; // 4
    }
    System.out.println(counter); // 5
}
  1. Tworzymy obiekt scannera.
  2. Przetwarzamy w pętli dopóki są nowe linie.
  3. Pobieramy nową linię (Uwaga: bez tej linii pętla będzie kręciła się w nieskończoność).
  4. Zliczamy ilość linii.
  5. Drukujemy ilość zliczonych linii.

Wynik:

90000000 // counter
Odczyt Scaner
61390 ms

Ta metoda jest prawie dwa razy wolniejsza od odczytu pojedynczych bajtów.

BufferedReader i odczyt linia po linii

Kolejna metoda to użycie FileReader i opakowanie go w BufferedReader. Buforowanie daje nam o wiele większą wydajność, więc jest to metoda dużo szybsza od odczytywania znak po znaku i także od Scannera (pamiętaj że Scanner ma dodatkowe funkcje, które mogą ci się przydać przy przetwarzaniu).

private static void readBufferedReader() throws IOException {
    try (BufferedReader br = new BufferedReader(new FileReader("bigFile.txt"))) { // 1
        String line;
        long counter = 0;
        while ((line = br.readLine()) != null) { // 2
            counter++; // 3
        }
        System.out.println(counter); 4
    }
}
  1. Tworzymy BufferedReader.
  2. Odczytujemy linie za pomocą metody readLine().
  3. Zliczamy linie.
  4. Drukujemy ilość zliczonych linii.

Wynik:

90000000 // counter
Odczyt BufferedReader
6781 ms

Prawie 10 razy szybciej od Scanera i 5 razy szybciej od odczytu pojedynczych bajtów.

RandomAccessFile, memory maped file i odczyt znak po znaku

Klasa RandomAccessFile służy do losowego zapisu i odczytu pliku. Znaczy to, że plik może być zapisywany i odczytywany w dowolnym jego miejscu. Wraz z mapowaniem tego pliku w pamięci, daje to bardzo wydajny sposób odczytu pliku.

private static void readMemoryMappedFile() throws IOException {
    try (RandomAccessFile file = new RandomAccessFile("bigFile.txt", "r")) { // 1

        MappedByteBuffer buffer = file.getChannel().map(FileChannel.MapMode.READ_ONLY, 0, file.getChannel().size()); // 2
        long counter = 0;
        byte character;
        for (int i = 0; i < buffer.limit(); i++) { // 3
            character = buffer.get(); // 4
            counter++; // 5
        }
        System.out.println(counter); // 5
    }
}
  1. Tworzymy instancję klasy RandomAccessFile (r – tylko do odczytu).
  2. Tworzymy MappedByteBuffer.
  3. Odczytujemy w pętli dane z bufora.
  4. Pobieramy znak z bufora.
  5. Drukujemy ilość zliczonych znaków.

Wynik:

1320000000 // counter
Odczyt readMemoryMappedFile
94 ms

Tylko 94 ms! Odczyt całego pliku, który ma 1,3 GB zajął nam tylko 94 ms. Dużo, dużo szybciej niż pozostałe metody odczytu. Metoda mapowania pliku do pamięci jest bardzo wydajna.

Porównanie wyników wydajnościowych

Wyniki wydają się jednoznaczne. Najszybszy odczyt plik następuje przez memory mapped file, ale sposób ten może powodować zwiększoną liczbę page faults (warto się zapoznać z tematem zanim się zacznie używać tego sposobu). Jeśli nie potrzebujemy super prędkości do przetwarzania naprawdę wielkich plików, to wystarczy użycie BufferedReader.

Podsumowanie

Jak widać z dużymi plikami w Javie można sobie poradzić na kilka sposobów. A co jeśli masz dziesiątki, a może setki dużych plików i jeden komputer nie jest w stanie sobie z tym poradzić ? Oczywiście do tego też powstały już zaawansowane narzędzia takie jak np. Hadoop, Apache Spark, czy Apache Flink, ale to już temat na kolejny artykuł.

 

PS: Jeśli masz jakieś sugestie dotyczące tego, czy innych artykułów napisz w komentarzu, będę Ci bardzo wdzięczny 😉

Żródła:

https://docs.oracle.com/javase/8/docs/api/java/io/FileReader.html
https://docs.oracle.com/javase/8/docs/api/java/util/Scanner.html
https://docs.oracle.com/javase/8/docs/api/java/io/BufferedReader.html
https://docs.oracle.com/javase/8/docs/api/java/util/RandomAccess.html
https://docs.oracle.com/javase/8/docs/api/java/nio/MappedByteBuffer.html

Mateusz Dąbrowski

Cześć jestem Mateusz, zajmuję się programowaniem już ponad 12 lat z czego ponad 8 programuję w Javie. Zapraszam Cię do lektury mojego bloga. Możesz przeczytać więcej o mnie >>TUTAJ<<

8 thoughts to “Jak radzić sobie z dużymi plikami w Javie ?”

  1. Dlaczego raz w wynikach jest w counter:
    1320000000 // counter
    ….
    a innym razem:
    90000000 // counter
    ….

    ?

    1. Raz zliczane są znaki, raz linie, dlatego te liczby się różnią.
      Sam licznik jest tylko po to, żeby za symulować jakiekolwiek działanie w pętli, inaczej trudno by było ocenić czy ta pętla w ogóle działa. A nawet, teoretycznie mogłaby zostać usunięta przez JIT jako „dead code”.

  2. W przykładzie RandomAccessFile można by skorzystać z CharBuffer, w tej chwili ilość znaków utf8 będzie źle zliczona. Przez znaki rozumiem ilość code points, np. „łódź” = 4 znaki

    1. Jasne masz rację. Ale counter i System.out.println(counter); są właściwie tylko po to żeby JIT nie usuną tej pętli przy optymalizacji 😉 więc nie skupiałem się na tym czy to się liczy dobrze, tylko czy cokolwiek mi się zlicza w pętli.

    1. Nie do końca rozumiem o co chciałeś zapytać. Generalnie wydajność zależy od dysku, jak masz szybszy dysk, to czytasz szybciej. Kolejna rzecz to system plików, niektóre systemy plików są wolniejsze od innych, na Windowsie dodatkowo możesz mieć jakiegoś antywirusa (np. Windows defender), który może sprawdzać pliki przed dostępem do nich itd. Czynników jest kilka. Im większy plik, im więcej dużych plików masz do przeczytania to większe znaczenie mogą mieć te wszystkie czynniki.

  3. Po korzystaniu z MappedByteBuffer plik pozostaje w użyciu przez proces. Jak go zamknąć? Używam javy 1.8.0._x. Niestety wyższej na razie nie mogę.

Komentarze są zamknięte.