CodeGym /Cursos /JAVA 25 SELF /Trabalhando com arquivos grandes: chunking, memory mappin...

Trabalhando com arquivos grandes: chunking, memory mapping

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

1. Chunking — leitura de arquivos em partes

Como já discutimos na aula anterior, o chunking permite trabalhar com arquivos por partes, sem carregá-los inteiros na memória. Isso é especialmente importante quando falamos de grandes volumes de dados. Se o arquivo tem 10 MB, geralmente não há problema — você pode carregá-lo e trabalhar com ele de qualquer maneira. Mas o que fazer se o arquivo chega a 10 GB, a memória RAM é de apenas 8 GB, e ainda há um navegador com dezenas de abas e a IDE abertos? A tentativa de ler um arquivo desses por inteiro normalmente termina de forma trágica: OutOfMemoryError, travamento do programa e lágrimas do desenvolvedor.

Exemplos reais de arquivos tão grandes aparecem o tempo todo: logs de servidores por um mês podem ocupar dezenas de gigabytes, arquivos CSV grandes contêm milhões de linhas, e vídeos, arquivos compactados e dumps de bancos de dados são ainda maiores.

A ideia principal continua a mesma: não tentar “comer o elefante de uma vez só”, e sim trabalhar em pedaços. É exatamente o chunking que permite processar esses dados com segurança e eficiência, dividindo o arquivo em partes gerenciáveis.

Relembrando o chunking

Chunk (pedaço, bloco) — é apenas uma parte do arquivo de um tamanho definido. Em vez de ler tudo de uma vez, lemos, por exemplo, 4 MB (ou 64 KB, ou 1 MB — dependendo da situação).

Princípio:

  • Abrimos um stream para leitura do arquivo.
  • Criamos um buffer — um array de bytes de tamanho fixo.
  • Em um loop, lemos do arquivo para o buffer até chegar ao fim.
  • Cada “pedaço” é processado separadamente.

Exemplo: copiando um arquivo grande em pedaços

Suponha que temos um arquivo enorme que precisa ser copiado. Vamos escrever um programa que faça isso “de forma profissional”.

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);
                // Você pode adicionar a exibição de progresso ou processamento dos dados
            }
        }
        System.out.println("Cópia concluída!");
    }
}

Para trabalhar com arquivos em Java, geralmente usamos os streams padrão FileInputStream e FileOutputStream. Uma boa prática é usar um buffer de cerca de 4 MB — isso é suficiente para discos modernos, garantindo leitura e escrita eficientes. No loop, o programa lê os pedaços do arquivo e os grava imediatamente no novo arquivo, sem tentar manter o arquivo inteiro na memória.

Essa abordagem economiza memória RAM, evita erros como OutOfMemoryError e permite trabalhar com arquivos praticamente de qualquer tamanho, mesmo 100 GB ou mais.

2. Chunking para processamento de dados

Muitas vezes a tarefa não é apenas copiar o arquivo, mas, por exemplo, encontrar uma determinada linha, contar ocorrências, substituir algo etc.

Exemplo: busca de uma string em um arquivo de texto grande

Se o arquivo for de texto, é mais conveniente usar streams de caracteres e leitura linha a linha:

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("Encontradas " + count + " linhas com ERROR");
    }
}

Por que isso funciona mesmo para arquivos de vários gigabytes?

  • BufferedReader lê o arquivo em pedaços (por padrão, buffer de 8 KB, mas é possível definir maior).
  • Na memória, a cada momento, fica apenas uma linha.

Buffer size: qual escolher?

Regra de ouro: buffer muito pequeno — muitas operações de I/O no disco; buffer grande demais — desperdício de memória.

  • Para HDD/SSD modernos, normalmente funciona bem um buffer de 64 KB – 4 MB.
  • Para SSDs muito rápidos ou em rede — pode ser maior (8–16 MB).
  • Para arquivos de texto — considere aumentar o buffer no BufferedReader.

Experimente! Meça o tempo de execução do programa com buffers diferentes. Às vezes aumentar o buffer proporciona aceleração de 2–3 vezes, às vezes — quase não muda.

3. Memory-mapped files (mapeamento de arquivo em memória)

O que é isso afinal?

Memory mapping é uma forma de “mapear” o arquivo diretamente na memória do processo usando mecanismos do sistema operacional. Em Java, usa-se a classe MappedByteBuffer do pacote java.nio. O arquivo passa a se comportar como um grande array de bytes, com o qual você pode trabalhar diretamente, sem leitura/escrita explícita de cada pedaço.

Essa abordagem é especialmente útil para arquivos muito grandes. O sistema operacional carrega automaticamente as partes necessárias do arquivo na memória, e você pode acessar qualquer posição no arquivo como se fosse um array comum. Memory-mapped files fornecem alta velocidade de acesso aleatório. Por exemplo, quando você precisa ler rapidamente partes do arquivo de lugares diferentes, sem carregá-lo por completo.

Como isso fica no código?

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 — tamanho de um mapeamento

            long position = 0;
            while (position < fileSize) {
                long size = Math.min(chunkSize, fileSize - position);
                MappedByteBuffer buffer = channel.map(FileChannel.MapMode.READ_ONLY, position, size);

                // Lemos os dados do buffer como de um array
                for (int i = 0; i < size; i++) {
                    byte b = buffer.get(i);
                    // Processamento do byte (por exemplo, procurar um determinado valor)
                }

                position += size;
            }
        }
        System.out.println("Leitura via memory mapping concluída!");
    }
}

RandomAccessFile e FileChannel permitem acessar o arquivo em baixo nível. A chamada channel.map mapeia um trecho do arquivo na memória. O acesso aos dados é feito por meio do buffer MappedByteBuffer.

Quais são as vantagens do memory mapping?

  • Muito rápido para acesso aleatório a diferentes partes do arquivo.
  • Permite trabalhar com arquivos maiores que a memória RAM disponível (o SO carrega automaticamente as páginas necessárias).
  • Usado em bancos de dados modernos, índices e grandes logs.

Quais são as desvantagens?

  • Nem sempre é adequado para escrita (especialmente em sistemas de arquivos de rede).
  • Limitações de tamanho de mapeamento (geralmente até 2 GB por mapeamento em JVMs de 32 bits).
  • Se esquecer de fechar o arquivo, ele pode ficar “preso” (especialmente no Windows).
  • Nem todas as operações com arquivos são aceleradas — se você precisa apenas de leitura sequencial, o buffer comum muitas vezes não fica atrás.

4. Exemplos práticos

Exemplo 1: Busca de substring em arquivo grande via memory mapping

Suponha que temos um arquivo de 10 GB e queremos encontrar nele uma determinada sequência de bytes (por exemplo, a string "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("Encontrado na posição " + (position + i));
                        // É possível parar a busca ou continuar
                    }
                }
                position += size;
            }
        }
    }
}

Atenção:
Se a substring puder “se quebrar” entre dois chunks, é necessário prever uma sobreposição entre os chunks no comprimento da sequência procurada.

5. Detalhes úteis

Quando usar chunking e quando usar memory mapping?

  • Chunking — abordagem universal para qualquer arquivo (texto, binário, logs, arquivos compactados). Funciona muito bem para processamento sequencial.
  • Memory mapping — super eficiente para acesso aleatório, trabalho com grandes índices, bancos de dados e buscas rápidas em arquivos enormes.

Se você não souber o que escolher — comece com chunking! Memory mapping é uma arma poderosa, porém mais “baixo nível”, que exige cuidado.

Recomendações

  • Use try-with-resources para fechamento automático de streams e canais.
  • Não abra arquivos demais simultaneamente: o SO tem limites para a quantidade de descritores abertos.
  • Não faça mapeamentos em pedaços grandes demais — isso pode levar a erros (especialmente em JVMs de 32 bits).
  • Para processamento paralelo, você pode dividir o arquivo em chunks e processá-los em threads separadas (mas é importante não “saturar” o disco nem ultrapassar os limites de memória).

6. Erros comuns ao trabalhar com arquivos grandes

Erro nº 1: tentar carregar o arquivo grande inteiro na memória.
Um problema muito comum — especialmente entre iniciantes. Se o arquivo for maior que 1–2 GB, use chunking ou leitura linha a linha; caso contrário, o programa “cairá” com OutOfMemoryError.

Erro nº 2: buffer muito pequeno.
Um buffer de 512 bytes não é otimização, e sim um lento suicídio de desempenho. Use buffers de 64 KB ou maiores.

Erro nº 3: esquecer de fechar o stream ou o canal.
O descritor de arquivo ficará pendurado, o arquivo não será removido ou liberado até reiniciar a JVM. Use try-with-resources.

Erro nº 4: uso incorreto de memory mapping.
Se o arquivo for alterado por outro processo durante o mapeamento, você pode obter dados inconsistentes ou um erro. Não use memory mapping para arquivos que mudam com frequência.

Erro nº 5: não considerar a sobreposição de chunks ao buscar substrings.
Se a string procurada puder ficar “na junção” de dois chunks, faça obrigatoriamente uma sobreposição entre os chunks do tamanho dessa string.

Comentários
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION