JavaRush /Курсы /JAVA 25 SELF /Многопоточное чтение и запись файлов

Многопоточное чтение и запись файлов

JAVA 25 SELF
59 уровень , 0 лекция
Открыта

1. Когда многопоточность помогает

Многопоточность нужна, когда есть много работы, которую можно делать одновременно. Например, если нужно обработать десятки файлов — скопировать их, пересчитать или проанализировать — проще поручить разные части разным потокам, чем делать всё последовательно. Это как если бы вместо одного друга, который перебирает ваш архив фотографий, вы позвали сразу пятерых: работа пойдёт быстрее и веселее.

Она особенно полезна, когда вы имеете дело с пакетной обработкой файлов, скачиванием или копированием больших файлов по частям, или когда после чтения данных нужно параллельно что-то посчитать для разных частей.

Но многопоточность не всегда помогает. Если у вас один маленький файл, запускать ради него десяток потоков бессмысленно. Если диск или сеть уже загружены под завязку, новые потоки только замедлят процесс. А если несколько потоков одновременно пишут в один и тот же файл без синхронизации — можно получить настоящий хаос с поврежденными данными.

Проще говоря, многопоточность — это инструмент. Как молоток: им можно забить гвоздь, а можно и по пальцу ударить. Главное — знать, когда и как его использовать.

2. Инструменты Java для многопоточного IO

Вы уже в курсе, что Java есть несколько способов запускать задачи параллельно:

  • Классический Thread — ручное создание потоков.
  • Пул потоков через ExecutorService — современный, гибкий и удобный способ.
  • CompletableFuture и параллельные стримы (Stream API) — для продвинутых задач (подробнее их разберем в следующих лекциях).

Начнем с самого простого: обработка нескольких файлов в разных потоках.

Пример 1: Классический Thread

public class FileCopyTask extends Thread {
    private final Path source;
    private final Path target;

    public FileCopyTask(Path source, Path target) {
        this.source = source;
        this.target = target;
    }

    @Override
    public void run() {
        try {
            Files.copy(source, target, StandardCopyOption.REPLACE_EXISTING);
            System.out.println("Файл скопирован: " + source);
        } catch (IOException e) {
            System.err.println("Ошибка копирования " + source + ": " + e.getMessage());
        }
    }
}
// Запуск нескольких копий в отдельных потоках
List<Path> filesToCopy = List.of(
    Path.of("log1.txt"), Path.of("log2.txt"), Path.of("log3.txt")
);
for (Path file : filesToCopy) {
    new FileCopyTask(file, Path.of("backup_" + file.getFileName())).start();
}

Плюсы: Просто, понятно.

Минусы: Управлять большим количеством потоков вручную неудобно, нет контроля над количеством одновременно работающих потоков.

Пример 2: ExecutorService — пул потоков

ExecutorService позволяет вам делегировать задачи пулу потоков, который сам решит, сколько потоков использовать одновременно.

import java.util.concurrent.*;

public class MultiFileCopier {
    public static void main(String[] args) throws InterruptedException {
        ExecutorService executor = Executors.newFixedThreadPool(4); // максимум 4 потока

        List<Path> filesToCopy = List.of(
            Path.of("log1.txt"), Path.of("log2.txt"), Path.of("log3.txt")
        );
        for (Path file : filesToCopy) {
            executor.submit(() -> {
                try {
                    Files.copy(file, Path.of("backup_" + file.getFileName()), StandardCopyOption.REPLACE_EXISTING);
                    System.out.println("Скопирован: " + file);
                } catch (IOException e) {
                    System.err.println("Ошибка: " + file + " " + e.getMessage());
                }
            });
        }

        executor.shutdown(); // больше не принимаем задачи
        executor.awaitTermination(1, TimeUnit.MINUTES); // ждем завершения всех задач
    }
}

Плюсы:

  • Легко масштабировать (можно задать нужное количество потоков).
  • Удобно контролировать завершение задач (методы shutdown(), awaitTermination(...)).
  • Подходит для обработки сотен и тысяч файлов.

3. Проблемы и ограничения многопоточного IO

Конкуренция за ресурсы

Если вы попытаетесь одновременно читать или писать один и тот же файл из нескольких потоков без синхронизации — результат будет непредсказуемым. Это как если бы два человека одновременно писали на одной и той же странице книги: получится каша. Для координации используйте, например, synchronized, явные блокировки или отдельный поток записи.

Ограничения файловой системы и ОС

  • Не все файловые системы хорошо поддерживают одновременную запись в один файл.
  • Операционная система может ограничивать количество одновременно открытых файлов.
  • Жесткий диск (особенно HDD) плохо работает при большом количестве случайных обращений.

Синхронизация при записи в общий ресурс

Если несколько потоков пишут в один файл (например, лог), обязательно используйте синхронизацию (например, через synchronized, блокировки, или специальные потоки записи).

Неэффективность при маленьких файлах

Для маленьких файлов накладные расходы на создание потоков и переключение между ними могут быть больше, чем выигрыш от параллелизма.

4. Практические примеры

Параллельное копирование файлов

Допустим, у нас есть папка с кучей логов, которые нужно скопировать в архивную директорию.

import java.nio.file.*;
import java.util.List;
import java.util.concurrent.*;

public class ParallelFileCopier {
    public static void main(String[] args) throws InterruptedException {
        ExecutorService executor = Executors.newFixedThreadPool(4);

        List<Path> filesToCopy = List.of(
            Path.of("log1.txt"), Path.of("log2.txt"), Path.of("log3.txt")
            // ... добавьте сколько угодно файлов
        );

        for (Path file : filesToCopy) {
            executor.submit(() -> {
                try {
                    Path target = Path.of("archive", file.getFileName().toString());
                    Files.copy(file, target, StandardCopyOption.REPLACE_EXISTING);
                    System.out.println("Скопирован: " + file);
                } catch (IOException e) {
                    System.err.println("Ошибка: " + file + " " + e.getMessage());
                }
            });
        }

        executor.shutdown();
        executor.awaitTermination(10, TimeUnit.MINUTES);
    }
}

Комментарий:

  • Мы используем пул из 4 потоков — этого обычно достаточно, чтобы загрузить диск, но не перегрузить систему.
  • Для 1000 файлов можно увеличить пул до 8, но не стоит делать его слишком большим.

Параллельная обработка строк файла с помощью Stream API

С Java 8 можно использовать параллельные стримы для обработки содержимого файла:

import java.nio.file.*;
import java.io.IOException;

public class ParallelLineProcessing {
    public static void main(String[] args) throws IOException {
        Path path = Path.of("biglog.txt");

        // Files.lines возвращает Stream<String> — поток строк файла
        Files.lines(path)
            .parallel() // превращаем в параллельный поток
            .filter(line -> line.contains("ERROR"))
            .forEach(line -> System.out.println("Ошибка: " + line));
    }
}

Важно:

  • Параллельные стримы ускоряют обработку, если она требует много вычислений (CPU-bound), а не сам процесс чтения (IO-bound).
  • Если обработка строки простая (например, просто печать через System.out.println), прироста может не быть.

Чтение/запись разных частей одного большого файла

Java позволяет читать или писать разные участки одного файла одновременно с помощью FileChannel и методов позиционирования. Это уже продвинутый уровень, но принцип простой: каждый поток работает со своим куском файла.

import java.nio.channels.FileChannel;
import java.nio.file.*;
import java.io.*;
import java.nio.ByteBuffer;

public class FileChunkReader implements Runnable {
    private final Path path;
    private final long position;
    private final long size;

    public FileChunkReader(Path path, long position, long size) {
        this.path = path;
        this.position = position;
        this.size = size;
    }

    @Override
    public void run() {
        try (FileChannel channel = FileChannel.open(path, StandardOpenOption.READ)) {
            ByteBuffer buffer = ByteBuffer.allocate((int) size);
            channel.read(buffer, position);
            System.out.println("Прочитан кусок с позиции " + position + " размером " + size);
            // Здесь можно обработать buffer
        } catch (IOException e) {
            System.err.println("Ошибка чтения куска: " + e.getMessage());
        }
    }
}
// Пример запуска: читаем файл по 1 МБ в 4 потока
Path file = Path.of("bigdata.bin");
long fileSize = Files.size(file);
long chunkSize = 1024 * 1024; // 1 МБ
int chunks = (int) Math.ceil((double) fileSize / chunkSize);

ExecutorService executor = Executors.newFixedThreadPool(4);

for (int i = 0; i < chunks; i++) {
    long position = i * chunkSize;
    long size = Math.min(chunkSize, fileSize - position);
    executor.submit(new FileChunkReader(file, position, size));
}

executor.shutdown();
executor.awaitTermination(10, TimeUnit.MINUTES);

Комментарий:

  • Каждый поток читает свой кусок файла, не мешая другим.
  • Такой подход используется, например, в торрентах и загрузчиках.

Синхронизация при записи в общий файл

Если несколько потоков пишут в один и тот же файл (например, лог), нужно синхронизировать доступ, чтобы не получить «винегрет» из строк:

import java.io.*;

public class SafeLogger {
    private final Writer writer;

    public SafeLogger(String filename) throws IOException {
        this.writer = new BufferedWriter(new FileWriter(filename, true));
    }

    public synchronized void log(String message) throws IOException {
        writer.write(message);
        writer.write(System.lineSeparator());
        writer.flush();
    }

    public void close() throws IOException {
        writer.close();
    }
}

Комментарий:

  • Метод log помечен как synchronized, чтобы только один поток писал в файл в данный момент.
  • Это работает, но при большом количестве потоков может стать узким местом — лучше писать в разные файлы, а потом объединять.

5. Когда не стоит использовать многопоточность

Многопоточность манит к себе: больше потоков, значит, всё должно летать! Но на практике это не всегда так. Если вы обрабатываете пару небольших файлов, то проще и надёжнее сделать это последовательно. Время, которое вы потратите на запуск потоков и координацию между ними, просто не окупится.

Иногда проблема вообще не в диске, а в сети — тогда добавление потоков ничего не ускорит, потому что bottleneck находится совсем в другом месте. Ещё одна ловушка — параллельная запись в один и тот же файл. Если у вас мало опыта с синхронизацией, лучше даже не пытаться: велик шанс получить испорченные данные.

И наконец, если ваш диск или файловая система не любят, когда их дёргают сразу десятки потоков, — многопоточность не спасёт, а только ухудшит ситуацию.

Проще говоря, если кажется, что «чем больше потоков, тем лучше», — чаще всего это не так. Иногда один спокойный поток делает работу чище, быстрее и надёжнее, чем десяток торопливых.

6. Краткое знакомство с FileChannel для продвинутых задач

FileChannel из пакета java.nio.channels — это инструмент для работы с файлами на низком уровне, позволяющий читать и писать данные по произвольным позициям. Это позволяет реализовать, например, параллельную загрузку или обработку больших файлов по кускам.

Пример:

try (FileChannel channel = FileChannel.open(Path.of("bigfile.bin"), StandardOpenOption.READ)) {
    ByteBuffer buffer = ByteBuffer.allocate(1024);
    long position = 0;
    int bytesRead = channel.read(buffer, position); // читаем 1024 байта с позиции 0
    // обработка buffer
}

Важно:

  • FileChannel не синхронизирован — если несколько потоков работают с одним каналом, синхронизацию нужно реализовать самостоятельно.
  • Для параллельной работы проще открывать отдельный канал для каждого потока.

7. Типичные ошибки при многопоточном IO

Ошибка №1: Несинхронизированный доступ к одному файлу на запись.
В результате — поврежденные данные, странные символы, иногда файл вообще не читается. Всегда синхронизируйте доступ или пишите в отдельные файлы.

Ошибка №2: Слишком много потоков.
Если вы открываете 1000 потоков ради копирования 1000 файлов, ваш компьютер может обидеться (OutOfMemoryError, тормоза, падения). Используйте пул потоков (ExecutorService) и ограничивайте их количество.

Ошибка №3: Не закрываются потоки/файлы.
Каждый открытый поток — это ресурс ОС. Если их не закрывать, можно получить ошибку «Too many open files». Используйте try-with-resources или не забывайте вызывать close().

Ошибка №4: Преждевременное завершение программы.
Если не дождаться завершения всех потоков (например, не вызвать executor.awaitTermination(...)), программа может завершиться раньше, чем все файлы скопируются.

Ошибка №5: Параллельная запись в один и тот же участок файла без учёта позиций.
Если несколько потоков пишут в одну и ту же область файла — данные перемешаются. Для записи по позициям используйте каналы и чёткое разделение диапазонов.

1
Задача
JAVA 25 SELF, 59 уровень, 0 лекция
Недоступна
Параллельное чтение частей большого файла
Параллельное чтение частей большого файла
1
Задача
JAVA 25 SELF, 59 уровень, 0 лекция
Недоступна
Синхронизированная запись в общий лог-файл из нескольких потоков
Синхронизированная запись в общий лог-файл из нескольких потоков
Комментарии (1)
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ
Andrey Уровень 1
9 ноября 2025
59