CodeGym /Cursos /JAVA 25 SELF /Divisão de arquivos grandes em partes

Divisão de arquivos grandes em partes

JAVA 25 SELF
Nível 59, Lição 3
Disponível

1. Por que ler arquivos grandes em uma única thread — é como carregar tijolos um por um

Quando você trabalha com arquivos grandes — dezenas ou centenas de megabytes, ou até gigabytes — a leitura ou escrita de uma única thread rapidamente vira um gargalo. Uma única thread simplesmente não dá conta da carga: o disco pode fornecer dados mais rápido do que o programa consegue processá-los.

Mesmo com um SSD rápido, o limitador não será o disco, mas as sobrecargas — trocas de contexto, trabalho com buffers, transformação de dados na memória. No fim, o desempenho cai e a CPU fica ociosa, pois os demais núcleos simplesmente não são utilizados.

Suponha que você decidiu contar o número de palavras em um log enorme. Se fizer isso de forma sequencial, uma única thread vai mastigar o arquivo monotonamente e você vai apenas esperar. Mas se dividir o arquivo em partes e delegar o processamento a várias threads, tudo anda bem mais rápido: cada uma processa sua parte e, no fim, você aproveita quase todo o potencial do disco.

Na prática, fica assim: em um SSD com throughput de 2 GB/s, a leitura de uma única thread rende apenas cerca de 300–500 MB/s. Lendo em paralelo, dá para extrair do dispositivo tudo o que ele pode oferecer.

2. Chunking — como fazer o arquivo trabalhar a seu favor

Quando o arquivo fica grande demais para ser processado por inteiro, o mais sensato — é dividi-lo em partes. Essa técnica se chama chunking (da palavra chunk — “pedaço”). A ideia é simples: você divide um arquivo grande em vários segmentos lógicos e atribui a cada thread a sua faixa.

Cada thread sabe a partir de qual deslocamento (offset) deve começar e onde parar. Ela lê apenas a própria parte, processa os dados e, depois, os resultados são agregados no total.

Essa abordagem permite usar todos os núcleos da CPU ao mesmo tempo e acelera bastante o processamento, especialmente se você tiver um SSD moderno ou um disco NVMe. Em tarefas como contagem de linhas, busca em texto ou agregação de estatísticas, o chunking funciona como um turbo — simplesmente adiciona velocidade sem muito esforço.

Como escolher o tamanho do chunk

O tamanho do chunk é quase como o tamanho de uma porção de comida: pequeno demais — você vai perder tempo cortando; grande demais — fica difícil de digerir. Tudo depende da tarefa e das capacidades da sua máquina.

Em média, faixas entre 8–64 MB por thread dão bons resultados. Para a maioria dos casos, algo em torno de 10–20 MB é suficiente, mas não existe número ideal — tudo é determinado experimentalmente. O principal — é que o pedaço seja grande o bastante para não desperdiçar tempo em trocas de threads, e não tão grande a ponto de saturar o cache da CPU ou ocupar toda a memória.

Se você trabalha com textos — por exemplo, contando palavras ou procurando correspondências — é importante que os chunks não “quebrem” linhas ou palavras no meio. Normalmente resolve-se isso de forma simples: cria-se uma pequena sobreposição entre as partes ou ajustam-se as fronteiras até o próximo caractere de quebra de linha. Assim o processamento permanece preciso e o resultado — limpo e previsível.

3. Ferramentas para acesso posicional: FileChannel e MappedByteBuffer

FileChannel: Positioned IO

FileChannel — é uma classe do pacote java.nio.channels que permite trabalhar com arquivos em baixo nível, inclusive ler e escrever dados em/para uma posição arbitrária do arquivo.

Métodos principais:

  • position(long newPosition) — define a posição (offset) para leitura/gravação.
  • read(ByteBuffer dst, long position) — lê dados do arquivo para o buffer a partir da posição indicada (não altera a posição atual do canal!).
  • write(ByteBuffer src, long position) — grava dados no arquivo a partir da posição indicada.

Exemplo: leitura de um pedaço do arquivo

try (FileChannel channel = FileChannel.open(Path.of("bigfile.txt"), StandardOpenOption.READ)) {
    long chunkSize = 16 * 1024 * 1024; // 16 MB
    long offset = 0;
    ByteBuffer buffer = ByteBuffer.allocate((int) chunkSize);
    int bytesRead = channel.read(buffer, offset);
    // o buffer contém os primeiros 16 MB do arquivo
}

Vantagens:

  • Pode ler/gravar de qualquer posição.
  • Conveniente para processamento paralelo: cada thread trabalha com a sua parte.

MappedByteBuffer: Memory-mapped files

MappedByteBuffer — é um buffer especial que permite “mapear” (map) uma parte do arquivo na memória. O sistema operacional cuida de carregar os dados do disco para a memória e vice-versa.

Como funciona?

  • Você mapeia um trecho do arquivo na memória.
  • Você lê e grava no buffer — o SO carrega automaticamente as páginas necessárias.
  • Não há chamadas explícitas a read/write — tudo ocorre via memória.

Exemplo:

try (FileChannel channel = FileChannel.open(Path.of("bigfile.txt"), StandardOpenOption.READ)) {
    long chunkSize = 16 * 1024 * 1024; // 16 MB
    long offset = 0;
    MappedByteBuffer buffer = channel.map(FileChannel.MapMode.READ_ONLY, offset, chunkSize);
    // Agora o buffer se comporta como um array de bytes, mas os dados são lidos do disco sob demanda
}

Vantagens:

  • Velocidade muito alta (especialmente em SSDs).
  • Simplicidade: você lê/grava como em um array.

Desvantagens:

  • Usa memória virtual — se o arquivo for muito grande, pode saturar a memória.
  • Controlar a desalocação é difícil (o buffer pode permanecer na memória por mais tempo do que o necessário).
  • Nem sempre é conveniente para arquivos muito grandes (acima de 2–4 GB em sistemas de 32 bits).

4. Exemplo: leitura paralela e contagem de palavras

Vamos considerar a tarefa: contar o número de palavras em um arquivo de texto grande (por exemplo, um log de 10 GB) por meio de processamento paralelo.

Etapa 1. Dividir o arquivo em chunks

  • Obtemos o tamanho do arquivo: long fileSize = Files.size(path);
  • Escolhemos o tamanho do chunk, por exemplo, 16 MB.
  • Para cada chunk, calculamos o deslocamento: offset = chunkIndex * chunkSize;
  • O último chunk pode ser menor.

Etapa 2. Criar tarefas para as threads

  • Para cada chunk, criamos Callable<Integer> (ou Runnable), que:
    • Abre a sua parte do arquivo via FileChannel.read(ByteBuffer, offset) ou MappedByteBuffer.
    • Conta a quantidade de palavras na sua parte.
    • Retorna o resultado (quantidade de palavras).

Etapa 3. Executar as tarefas via ExecutorService

  • Criamos um pool de threads: ExecutorService pool = Executors.newFixedThreadPool(N);
  • Enviamos as tarefas para o pool: List<Future<Integer>> results = pool.invokeAll(tasks);
  • Agregamos os resultados: somamos os valores de todos os Futures.

Exemplo de código (simplificado):

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

public class ParallelWordCount {
    public static void main(String[] args) throws Exception {
        Path path = Path.of("bigfile.txt");
        long fileSize = Files.size(path);
        int chunkSize = 16 * 1024 * 1024; // 16 MB
        int chunks = (int) ((fileSize + chunkSize - 1) / chunkSize);

        ExecutorService pool = Executors.newFixedThreadPool(Runtime.getRuntime().availableProcessors());
        List<Future<Integer>> results = new ArrayList<>();

        for (int i = 0; i < chunks; i++) {
            long offset = (long) i * chunkSize;
            long size = Math.min(chunkSize, fileSize - offset);

            results.add(pool.submit(() -> {
                try (FileChannel channel = FileChannel.open(path, StandardOpenOption.READ)) {
                    MappedByteBuffer buffer = channel.map(FileChannel.MapMode.READ_ONLY, offset, size);
                    byte[] bytes = new byte[(int) size];
                    buffer.get(bytes);
                    String text = new String(bytes);
                    // Importante: tratar as fronteiras do chunk para não cortar uma palavra!
                    return countWords(text);
                }
            }));
        }

        int totalWords = 0;
        for (Future<Integer> f : results) {
            totalWords += f.get();
        }
        pool.shutdown();
        System.out.println("Total words: " + totalWords);
    }

    private static int countWords(String text) {
        // Maneira mais simples: dividir por espaços e filtrar strings vazias
        String[] words = text.split("\\s+");
        int count = 0;
        for (String w : words) {
            if (!w.isBlank()) count++;
        }
        return count;
    }
}

Atenção: em casos reais é preciso tratar cuidadosamente as fronteiras dos chunks para não cortar uma palavra ou linha entre duas threads. Normalmente cria-se uma pequena sobreposição (por exemplo, +100 bytes) e ajusta-se o início/fim do chunk.

5. Conclusões e boas práticas

  • Para arquivos grandes, use divisão em chunks e processamento paralelo.
  • Use FileChannel para acesso posicional e MappedByteBuffer para arquivos mapeados em memória.
  • Escolha o tamanho do chunk por experimentação; leve em conta o cache da CPU e o throughput do disco.
  • Trate cuidadosamente as fronteiras dos chunks (especialmente para textos).
  • Para processamento paralelo, use ExecutorService e um pool de threads.
  • Não exagere no número de threads: normalmente 2–4 threads bastam em SSDs.
  • Fique atento ao consumo de memória: MappedByteBuffer pode ocupar muita memória virtual.

6. Erros comuns ao trabalhar com arquivos grandes e chunking

Erro nº 1: Ler o arquivo inteiro na memória. Ao processar arquivos grandes isso pode levar a OutOfMemoryError. Em vez disso, leia os dados em partes (chunks).

Erro nº 2: Tratamento incorreto das fronteiras dos chunks. Se cortar o arquivo sem considerar limites de linhas ou palavras, você pode “quebrar” os dados, e o resultado ficará incorreto.

Erro nº 3: Tamanho de chunk não otimizado. Chunks pequenos demais geram sobrecarga extra de gerenciamento de threads, e chunks grandes demais usam a memória de forma ineficiente.

Erro nº 4: FileChannel não fechado. Isso leva a vazamento de recursos. Use try-with-resources para garantir o fechamento do canal.

Erro nº 5: Quantidade excessiva de threads. Se houver threads demais, o disco não consegue atender às requisições, e o desempenho cai em vez de melhorar.

1
Tarefa
JAVA 25 SELF, nível 59, lição 3
Bloqueado
Leitura de um trecho de arquivo com MappedByteBuffer
Leitura de um trecho de arquivo com MappedByteBuffer
1
Tarefa
JAVA 25 SELF, nível 59, lição 3
Bloqueado
Contagem paralela de linhas em um arquivo grande
Contagem paralela de linhas em um arquivo grande
Comentários
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION