CodeGym /Kursy /JAVA 25 SELF /Praca z dużymi plikami: chunking, memory mapping

Praca z dużymi plikami: chunking, memory mapping

JAVA 25 SELF
Poziom 41 , Lekcja 3
Dostępny

1. Chunking — odczyt pliku partiami

Jak już omawialiśmy w poprzednim wykładzie, chunking pozwala pracować z plikami partiami, bez ładowania ich w całości do pamięci. Jest to szczególnie ważne przy dużych wolumenach danych. Jeśli plik ma 10 MB, zwykle nie ma problemu — można go spokojnie wczytać i pracować z nim w dowolny sposób. Ale co zrobić, gdy plik ma 10 GB, a pamięci operacyjnej jest tylko 8 GB, do tego przeglądarka z dziesiątkami kart i otwarta IDE? Próba odczytu takiego pliku w całości zwykle kończy się tragicznie: OutOfMemoryError, zawieszenie programu i łzy programisty.

Realne przykłady takich dużych plików spotyka się nieustannie: logi serwerów za miesiąc mogą zajmować dziesiątki gigabajtów, duże pliki CSV zawierają miliony wierszy, a wideo, archiwa i zrzuty baz danych są jeszcze większe.

Główna idea pozostaje ta sama: nie próbować „zjeść słonia w całości”, lecz pracować kawałkami. To właśnie chunking pozwala bezpiecznie i efektywnie przetwarzać takie dane, dzieląc plik na łatwe do opanowania części.

Jeszcze raz o chunkingu

Chunk (kawałek, blok) to po prostu część pliku o określonym rozmiarze. Zamiast czytać wszystko naraz, czytamy na przykład po 4 MB (albo po 64 KB, albo po 1 MB — w zależności od sytuacji).

Zasada:

  • Otwieramy strumień do odczytu pliku.
  • Tworzymy bufor — tablicę bajtów o stałym rozmiarze.
  • W pętli czytamy z pliku do bufora, aż do końca.
  • Każdy „kawałek” przetwarzamy osobno.

Przykład: kopiowanie dużego pliku kawałkami

Załóżmy, że mamy ogromny plik, który trzeba skopiować. Napiszmy program, który zrobi to „na poważnie”.

import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.IOException;

public class BigFileCopy {
    public static void main(String[] args) throws IOException {
        String source = "bigfile.dat";
        String dest = "bigfile_copy.dat";
        int bufferSize = 4 * 1024 * 1024; // 4 MB

        try (FileInputStream in = new FileInputStream(source);
             FileOutputStream out = new FileOutputStream(dest)) {

            byte[] buffer = new byte[bufferSize];
            int bytesRead;
            while ((bytesRead = in.read(buffer)) != -1) {
                out.write(buffer, 0, bytesRead);
                // Można dodać wyświetlanie postępu lub przetwarzanie danych
            }
        }
        System.out.println("Kopiowanie zakończone!");
    }
}

Do pracy z plikami w Javie zwykle używa się standardowych strumieni FileInputStream i FileOutputStream. Dobrą praktyką jest użycie bufora o rozmiarze około 4 MB — to wystarczy dla nowoczesnych dysków, aby odczyt i zapis były efektywne. W pętli program czyta kawałki pliku i od razu zapisuje je do nowego pliku, nie próbując trzymać całego pliku w pamięci.

Takie podejście pozwala oszczędzać pamięć operacyjną, unikać błędów typu OutOfMemoryError i pracować z plikami praktycznie dowolnego rozmiaru, nawet jeśli to 100 GB i więcej.

2. Chunking do przetwarzania danych

Często zadanie to nie tylko skopiować plik, ale na przykład znaleźć określoną frazę, policzyć liczbę wystąpień, coś zamienić itd.

Przykład: wyszukiwanie frazy w dużym pliku tekstowym

Jeśli plik jest tekstowy, wygodniej jest użyć strumieni znakowych i odczytu liniami:

import java.io.BufferedReader;
import java.io.FileReader;
import java.io.IOException;

public class BigFileSearch {
    public static void main(String[] args) throws IOException {
        String file = "biglog.txt";
        String keyword = "ERROR";
        int count = 0;

        try (BufferedReader reader = new BufferedReader(new FileReader(file))) {
            String line;
            while ((line = reader.readLine()) != null) {
                if (line.contains(keyword)) {
                    count++;
                }
            }
        }
        System.out.println("Znaleziono " + count + " linii z ERROR");
    }
}

Dlaczego to działa nawet dla gigabajtowych plików?

  • BufferedReader czyta plik partiami (domyślnie bufor 8 KB, ale można ustawić większy).
  • W pamięci w danym momencie znajduje się tylko jedna linia.

Rozmiar bufora: jaki wybrać?

Złota zasada: zbyt mały bufor — dużo operacji dyskowych, zbyt duży — niepotrzebnie marnuje pamięć.

  • Dla nowoczesnych HDD/SSD zwykle dobrze sprawdza się bufor 64 KB – 4 MB.
  • Dla sieciowych lub bardzo szybkich SSD — można więcej (8–16 MB).
  • Dla plików tekstowych — można zwiększyć bufor w BufferedReader.

Eksperymentuj! Mierz czas działania programu z różnymi buforami. Czasem zwiększenie bufora daje przyspieszenie 2–3 razy, a czasem — prawie nie wpływa.

3. Pliki mapowane w pamięci (memory-mapped files)

Co to w ogóle jest?

Memory mapping to sposób „odwzorowania” pliku bezpośrednio w pamięci procesu za pomocą mechanizmów systemu operacyjnego. W Javie służy do tego klasa MappedByteBuffer z pakietu java.nio. Plik staje się jakby dużą tablicą bajtów, z którą można pracować bezpośrednio, bez jawnego odczytu i zapisu każdego kawałka.

Takie podejście jest szczególnie przydatne przy pracy z bardzo dużymi plikami. System operacyjny sam doładowuje potrzebne części pliku do pamięci, a ty możesz odwołać się do dowolnego miejsca w pliku tak, jakby to była zwykła tablica. Pliki mapowane w pamięci zapewniają wysoką prędkość dostępu losowego. Na przykład wtedy, gdy trzeba szybko czytać fragmenty pliku z różnych miejsc, nie ładując go w całości.

Jak to wygląda w kodzie?

import java.io.RandomAccessFile;
import java.nio.MappedByteBuffer;
import java.nio.channels.FileChannel;

public class MemoryMappedRead {
    public static void main(String[] args) throws Exception {
        String fileName = "bigfile.dat";
        try (RandomAccessFile file = new RandomAccessFile(fileName, "r");
             FileChannel channel = file.getChannel()) {

            long fileSize = channel.size();
            int chunkSize = 1024 * 1024 * 128; // 128 MB — rozmiar jednego mapowania

            long position = 0;
            while (position < fileSize) {
                long size = Math.min(chunkSize, fileSize - position);
                MappedByteBuffer buffer = channel.map(FileChannel.MapMode.READ_ONLY, position, size);

                // Czytamy dane z buffer jak z tablicy
                for (int i = 0; i < size; i++) {
                    byte b = buffer.get(i);
                    // Przetwarzanie bajtu (np. szukamy określonej wartości)
                }

                position += size;
            }
        }
        System.out.println("Odczyt przez memory mapping zakończony!");
    }
}

RandomAccessFile i FileChannel pozwalają uzyskać dostęp do pliku na niskim poziomie. Wywołanie channel.map odwzorowuje fragment pliku w pamięci. Dostęp do danych odbywa się przez bufor MappedByteBuffer.

Jakie są zalety memory mappingu?

  • Bardzo szybki dla dostępu losowego do różnych części pliku.
  • Można pracować z plikami większymi niż dostępna pamięć operacyjna (system operacyjny sam doładowuje potrzebne strony).
  • Wykorzystywany we współczesnych bazach danych, indeksach, dużych logach.

Jakie są wady?

  • Nie zawsze nadaje się do zapisu (zwłaszcza w sieciowych systemach plików).
  • Ograniczenia rozmiaru mapowania (zwykle do 2 GB na jedno mapowanie w 32‑bitowych JVM).
  • Jeśli zapomnieć zamknąć plik, może dojść do „zablokowania” pliku (zwłaszcza w Windows).
  • Nie wszystkie operacje na plikach przyspieszają — jeśli trzeba tylko sekwencyjnie czytać plik, zwykły bufor często wcale nie ustępuje.

4. Praktyczne przykłady

Przykład 1: Wyszukiwanie podciągu w dużym pliku przez memory mapping

Załóżmy, że mamy plik 10 GB i chcemy znaleźć w nim określoną sekwencję bajtów (np. ciąg "SECRET").

import java.io.RandomAccessFile;
import java.nio.MappedByteBuffer;
import java.nio.channels.FileChannel;
import java.nio.charset.StandardCharsets;

public class MemoryMappedSearch {
    public static void main(String[] args) throws Exception {
        String fileName = "hugefile.bin";
        byte[] target = "SECRET".getBytes(StandardCharsets.UTF_8);

        try (RandomAccessFile file = new RandomAccessFile(fileName, "r");
             FileChannel channel = file.getChannel()) {

            long fileSize = channel.size();
            int chunkSize = 128 * 1024 * 1024; // 128 MB

            long position = 0;
            while (position < fileSize) {
                long size = Math.min(chunkSize, fileSize - position);
                MappedByteBuffer buffer = channel.map(FileChannel.MapMode.READ_ONLY, position, size);

                for (int i = 0; i < size - target.length; i++) {
                    boolean found = true;
                    for (int j = 0; j < target.length; j++) {
                        if (buffer.get(i + j) != target[j]) {
                            found = false;
                            break;
                        }
                    }
                    if (found) {
                        System.out.println("Znaleziono na pozycji " + (position + i));
                        // Można zatrzymać wyszukiwanie lub kontynuować
                    }
                }
                position += size;
            }
        }
    }
}

Zwróć uwagę:
Jeśli podciąg może „rozerwać się” między dwoma chunkami, trzeba przewidzieć nakładanie między chunkami o długość szukanego ciągu.

5. Przydatne niuanse

Kiedy używać chunkingu, a kiedy — memory mappingu?

  • Chunking — uniwersalne podejście dla dowolnych plików (tekst, binarne, logi, archiwa). Dobrze sprawdza się przy przetwarzaniu sekwencyjnym.
  • Memory mapping — superwydajny dla dostępu losowego, pracy z dużymi indeksami, bazami danych, szybkich wyszukiwań po ogromnych plikach.

Jeśli nie wiesz, co wybrać — zacznij od chunkingu! Memory mapping to potężne, ale bardziej „niskopoziomowe” narzędzie, wymagające ostrożności.

Rekomendacje

  • Używaj try-with-resources do automatycznego zamykania strumieni i kanałów.
  • Nie otwieraj zbyt wielu plików jednocześnie: system operacyjny ma limity liczby otwartych deskryptorów.
  • Nie twórz mapowań na zbyt duże fragmenty — może to prowadzić do błędów (zwłaszcza w 32‑bitowych JVM).
  • Do przetwarzania równoległego można dzielić plik na chunki i przetwarzać je w osobnych wątkach (ale ważne, aby nie przeciążyć dysku i nie wyjść poza dostępne zasoby pamięci).

6. Typowe błędy przy pracy z dużymi plikami

Błąd nr 1: próba załadowania całego dużego pliku do pamięci.
Bardzo częsty problem — zwłaszcza u początkujących. Jeśli plik ma więcej niż 1–2 GB, użyj chunkingu albo odczytu wierszami, w przeciwnym razie program zakończy się błędem OutOfMemoryError.

Błąd nr 2: zbyt mały bufor.
Bufor 512 bajtów to nie optymalizacja, lecz powolne samobójstwo dla wydajności. Używaj buforów od 64 KB w górę.

Błąd nr 3: zapomniano zamknąć strumień lub kanał.
Deskryptor pliku pozostanie wisieć, plik nie zostanie usunięty ani zwolniony aż do ponownego uruchomienia JVM. Używaj try-with-resources.

Błąd nr 4: niewłaściwa praca z memory mappingiem.
Jeśli plik jest zmieniany przez inny proces w czasie mapowania, można uzyskać niespójne dane lub błąd. Nie używaj memory mappingu dla plików, które często się zmieniają.

Błąd nr 5: nieuwzględnienie nakładania chunków przy wyszukiwaniu podciągów.
Jeśli szukany ciąg może znaleźć się „na styku” dwóch chunków, koniecznie zrób nakładanie o długość tego ciągu między chunkami.

1
Zadanie
JAVA 25 SELF, poziom 41, lekcja 3
Niedostępne
Przerzucanie masywnych danych: Operacja "Gigantyczna Ciężarówka" 🚚
Przerzucanie masywnych danych: Operacja "Gigantyczna Ciężarówka" 🚚
1
Zadanie
JAVA 25 SELF, poziom 41, lekcja 3
Niedostępne
Archeologiczne poszukiwanie run w cyfrowym monolicie 📜
Archeologiczne poszukiwanie run w cyfrowym monolicie 📜
Komentarze
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION