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.
GO TO FULL VERSION