1. Chunking — читання файлу частинами
Як ми вже обговорювали в попередній лекції, chunking дозволяє працювати з файлами частинами, не завантажуючи їх повністю в пам’ять. Це особливо важливо, коли йдеться про великі обсяги даних. Якщо файл важить 10 МБ, зазвичай проблем немає — його можна спокійно завантажити й працювати з ним будь‑яким способом. Але що робити, якщо файл сягає 10 ГБ, а оперативної пам’яті всього 8 ГБ, та ще й браузер із десятками вкладок і IDE відкриті? Спроба прочитати такий файл цілком зазвичай закінчується трагічно: OutOfMemoryError, зависання програми й сльози розробника.
Реальні приклади таких великих файлів трапляються постійно: логи серверів за місяць можуть займати десятки гігабайтів, великі CSV‑файли містять мільйони рядків, а відео, архіви та дампи баз даних — ще більше.
Головна ідея залишається незмінною: не намагатися «з’їсти слона цілком», а працювати шматочками. Саме chunking дозволяє безпечно й ефективно обробляти такі дані, поділяючи файл на керовані частини.
Ще раз про chunking
Chunk (фрагмент, блок) — це просто частина файлу певного розміру. Замість того щоб читати все одразу, ми читаємо, наприклад, по 4 МБ (або по 64 КБ, або по 1 МБ — залежно від ситуації).
Принцип:
- Відкриваємо потік для читання файлу.
- Створюємо буфер — масив байтів фіксованого розміру.
- У циклі читаємо з файлу в буфер, доки не дійдемо до кінця.
- Кожен «шматок» обробляємо окремо.
Приклад: копіювання великого файлу частинами
Припустімо, у нас є величезний файл, який потрібно скопіювати. Напишімо програму, що робить це «по‑дорослому».
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 МБ
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);
// Можна додати виведення прогресу або обробку даних
}
}
System.out.println("Копіювання завершено!");
}
}
Для роботи з файлами в Java зазвичай використовують стандартні потоки FileInputStream і FileOutputStream. Хорошою практикою вважається використовувати буфер розміром близько 4 МБ — цього достатньо для сучасних дисків, щоб читання й запис відбувалися ефективно. У циклі програма читає фрагменти файлу й одразу записує їх у новий файл, не намагаючись тримати весь файл у пам’яті.
Такий підхід дозволяє заощаджувати оперативну пам’ять, уникати помилок на кшталт OutOfMemoryError і працювати з файлами практично будь‑якого розміру, навіть якщо це 100 ГБ і більше.
2. Chunking для обробки даних
Часто завдання — не просто скопіювати файл, а, наприклад, знайти певний рядок, порахувати кількість входжень, замінити щось тощо.
Приклад: пошук рядка у великому текстовому файлі
Якщо файл текстовий, зручніше використовувати символьні потоки та построчне читання:
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("Знайдено " + count + " рядків із ERROR");
}
}
Чому це працює навіть для гігабайтних файлів?
- BufferedReader читає файл частинами (за замовчуванням буфер 8 КБ, але можна задати більше).
- У пам’яті в кожен момент часу зберігається лише один рядок.
Розмір буфера: який обрати?
Золоте правило: занадто малий буфер — багато звернень до диска, занадто великий — марнується пам’ять.
- Для сучасних HDD/SSD зазвичай добре працює буфер 64 КБ – 4 МБ.
- Для мережевих або дуже швидких SSD — можна більше (8–16 МБ).
- Для текстових файлів — можна збільшити буфер у BufferedReader.
Експериментуйте! Вимірюйте час роботи програми з різними буферами. Іноді збільшення буфера дає прискорення в 2–3 рази, іноді — майже не впливає.
3. Memory-mapped files (відображення файлу в пам’ять)
Що це взагалі таке?
Memory mapping — це спосіб «відобразити» файл прямо в пам’ять процесу за допомогою механізмів операційної системи. У Java для цього використовується клас MappedByteBuffer із пакета java.nio. Файл ніби стає великим масивом байтів, із яким можна працювати напряму, без явного читання та запису кожного фрагмента.
Такий підхід особливо корисний для роботи з дуже великими файлами. Операційна система сама підвантажує потрібні частини файлу в пам’ять, а ви можете звертатися до будь‑якого місця у файлі так, наче це звичайний масив. Memory-mapped files забезпечують високу швидкість довільного доступу. Наприклад, коли потрібно швидко читати частини файлу з різних місць, не завантажуючи його повністю.
Як це виглядає в коді?
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 МБ — розмір одного мапінгу
long position = 0;
while (position < fileSize) {
long size = Math.min(chunkSize, fileSize - position);
MappedByteBuffer buffer = channel.map(FileChannel.MapMode.READ_ONLY, position, size);
// Читаємо дані з buffer, як із масиву
for (int i = 0; i < size; i++) {
byte b = buffer.get(i);
// Обробка байта (наприклад, шукаємо певне значення)
}
position += size;
}
}
System.out.println("Читання через memory mapping завершено!");
}
}
RandomAccessFile і FileChannel дозволяють отримати доступ до файлу на низькому рівні. Виклик channel.map відображає ділянку файлу в пам’ять. Доступ до даних — через буфер MappedByteBuffer.
У чому переваги memory mapping?
- Дуже швидко для довільного доступу до різних частин файлу.
- Можна працювати з файлами, які більші за доступну оперативну пам’ять (ОС сама підвантажує потрібні сторінки).
- Використовується в сучасних базах даних, індексах, великих логах.
У чому недоліки?
- Не завжди підходить для запису (особливо на мережевих файлових системах).
- Обмеження за розміром мапінгу (зазвичай до 2 ГБ на один мапінг у 32‑бітних JVM).
- Якщо забути закрити файл, може виникнути «залипання» файлу (особливо у Windows).
- Не всі операції з файлами прискорюються — якщо потрібно просто послідовно читати файл, звичайний буфер часто не поступається.
4. Практичні приклади
Приклад 1: пошук підрядка у великому файлі через memory mapping
Припустімо, у нас є файл на 10 ГБ, і ми хочемо знайти в ньому певну послідовність байтів (наприклад, рядок "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 МБ
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("Знайдено на позиції " + (position + i));
// Можна зупинити пошук або продовжити
}
}
position += size;
}
}
}
}
Зверніть увагу:
Якщо підрядок може «розірватися» між двома чанками, потрібно передбачити перекриття між чанками на довжину шуканої послідовності.
5. Корисні нюанси
Коли використовувати chunking, а коли — memory mapping?
- Chunking — універсальний підхід для будь‑яких файлів (текст, бінарні, логи, архіви). Добре працює для послідовної обробки.
- Memory mapping — надзвичайно ефективний для довільного доступу, роботи з великими індексами, базами даних, швидких пошуків по величезних файлах.
Якщо не знаєте, що обрати — почніть із chunking! Memory mapping — потужне, але більш «низькорівневе» знаряддя, що потребує акуратності.
Рекомендації
- Використовуйте try-with-resources для автоматичного закриття потоків і каналів.
- Не відкривайте надто багато файлів одночасно: в ОС є ліміти на кількість відкритих дескрипторів.
- Не робіть мапінг на надто великі фрагменти — це може призвести до помилок (особливо на 32‑бітних JVM).
- Для паралельної обробки можна розбивати файл на чанки й обробляти їх в окремих потоках (але важливо не «забити» диск і не вийти за межі пам’яті).
6. Типові помилки під час роботи з великими файлами
Помилка №1: спроба завантажити весь великий файл у пам’ять.
Дуже поширена проблема — особливо у новачків. Якщо файл більший за 1–2 ГБ, використовуйте chunking або построчне читання, інакше програма «впаде» з OutOfMemoryError.
Помилка №2: занадто малий буфер.
Буфер на 512 байт — це не оптимізація, а повільне самогубство для продуктивності. Використовуйте буфери від 64 КБ і вище.
Помилка №3: забули закрити потік або канал.
Файловий дескриптор залишиться відкритим, файл не видалиться або не звільниться до перезапуску JVM. Використовуйте try-with-resources.
Помилка №4: неправильна робота з мапінгом пам’яті.
Якщо файл змінюється іншим процесом під час мапінгу, можна отримати неконсистентні дані або помилку. Не використовуйте мапінг пам’яті для файлів, які часто змінюються.
Помилка №5: не враховується перекриття чанків під час пошуку підрядків.
Якщо шуканий рядок може опинитися «на стику» двох чанків, обов’язково робіть перекриття на довжину цього рядка між чанками.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ