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