Leia arquivos em Java com mapeamento de memória
O FileChannel.map() do Java permite ler os bytes de um arquivo por meio de um
MappedByteBuffer. Para testá-lo em uma tarefa concreta, o programa abaixo calcula o
checksum SHA-256 de um arquivo usando mapeamento de memória ou leituras em buffer. Os dois modos
processam os mesmos bytes, então você pode verificar a correção antes de comparar o desempenho
deles nos seus arquivos.
Entendendo arquivos mapeados em memória
Um mapeamento expõe uma região de um arquivo por meio da memória virtual. Acessar os bytes dessa região pode exigir que o sistema operacional carregue páginas do arquivo na RAM; mapear um arquivo não significa que todos os bytes já estejam residentes na memória. A documentação de buffers diretos do Java descreve buffers mapeados como buffers diretos cujo conteúdo fica fora do heap comum do Java.
Este exemplo calcula o hash dos bytes sem decodificá-los. Texto, caracteres UTF-8 e dados binários
seguem o mesmo caminho. Não há validação de codificação nem normalização de quebras de linha. Se,
em vez disso, você decodificar um mapeamento inteiro como String, os
caracteres decodificados e a string resultante ainda vão exigir espaço no heap proporcional ao
tamanho do texto. O mapeamento não elimina essa alocação.
Limitações importantes
Use um arquivo regular que não mude e um JDK 21 de 64 bits. Os comandos abaixo usam Bash e foram testados no Linux com OpenJDK 21.0.12.1; o comportamento de arquivos no Windows e no macOS não foi testado aqui.
O FileChannel.map() de três argumentos
aceita no máximo Integer.MAX_VALUE bytes por região: 2.147.483.647 bytes, ou 2 GiB
menos um byte. O modo mapeado abaixo rejeita arquivos maiores. A contraparte com buffer consegue
ler arquivos maiores usando um buffer reutilizável de 64 KiB. Usar vários mapeamentos é outra
opção, mas este exemplo usa uma única região para deixar visíveis o tempo de vida e o limite de
tamanho dela.
Não modifique nem trunque a entrada durante nenhuma das leituras. Um mapeamento somente leitura não
congela o arquivo, e o truncamento pode tornar bytes mapeados inacessíveis, como explica o
contrato de MappedByteBuffer. Nenhum dos modos
oferece um snapshot de um arquivo que esteja sendo escrito por outro processo.
Usando Java NIO para arquivos mapeados em memória
Salve isto como FileChecksum.java em um diretório de trabalho de sua escolha. O
programa não precisa de dependências além do JDK.
import java.io.IOException;
import java.nio.ByteBuffer;
import java.nio.MappedByteBuffer;
import java.nio.channels.FileChannel;
import java.nio.file.Path;
import java.nio.file.StandardOpenOption;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.HexFormat;
public class FileChecksum {
private static byte[] checksum(Path path, boolean mapped)
throws IOException, NoSuchAlgorithmException {
MessageDigest digest = MessageDigest.getInstance("SHA-256");
try (FileChannel channel = FileChannel.open(path, StandardOpenOption.READ)) {
long size = channel.size();
if (size == 0) {
return digest.digest();
}
if (mapped) {
if (size > Integer.MAX_VALUE) {
throw new IllegalArgumentException(
"Mapped mode supports at most 2147483647 bytes; use buffered mode");
}
MappedByteBuffer buffer = channel.map(FileChannel.MapMode.READ_ONLY, 0, size);
digest.update(buffer);
} else {
ByteBuffer buffer = ByteBuffer.allocate(64 * 1024);
while (channel.read(buffer) != -1) {
buffer.flip();
digest.update(buffer);
buffer.clear();
}
}
}
return digest.digest();
}
public static void main(String[] args) throws IOException, NoSuchAlgorithmException {
if (args.length != 2 || !(args[0].equals("mapped") || args[0].equals("buffered"))) {
throw new IllegalArgumentException(
"Usage: java FileChecksum <mapped|buffered> <path>");
}
byte[] result = checksum(Path.of(args[1]), args[0].equals("mapped"));
System.out.println(HexFormat.of().formatHex(result));
}
}
MessageDigest.update(ByteBuffer)
consome os bytes entre a posição e o limite do buffer. No modo com buffer,
flip() expõe apenas os bytes recém-lidos, incluindo um último bloco
menor, e clear() prepara o buffer para ser reutilizado. O ramo para
arquivo vazio calcula o digest sem criar um mapeamento.
Crie uma entrada de três bytes contendo abc, sem quebra de linha no
final, e depois compile e execute os dois modos. A configuração noclobber do
subshell impede que um example.txt existente seja sobrescrito. A compilação
substitui FileChecksum.class neste diretório de trabalho; as execuções de checksum
apenas leem a entrada e imprimem na saída padrão.
(set -o noclobber; printf 'abc' > example.txt) &&
javac FileChecksum.java &&
java FileChecksum mapped example.txt &&
java FileChecksum buffered example.txt
Os dois comandos devem imprimir este checksum:
ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad
Para reutilizar um arquivo existente, pule a etapa de criação e passe o caminho dele para qualquer
um dos modos, colocando entre aspas os caminhos que contêm espaços. Um arquivo vazio é processado
com sucesso e imprime
e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855.
Arquivos ausentes, argumentos inválidos e um mapeamento grande demais lançam uma exceção: o Java
escreve o diagnóstico na saída de erro padrão e encerra com um status de saída diferente de zero. O
programa não imprime um checksum quando a leitura falha.
Considerações de desempenho
O mapeamento tem custos de configuração. A
documentação de FileChannel observa que ler
uma pequena quantidade de dados da forma convencional pode custar menos do que criar um mapeamento.
Um checksum sequencial também gasta tempo calculando o hash, então um método mais rápido de acesso
ao arquivo pode fazer pouca diferença no tempo total de execução.
Use o mesmo arquivo representativo e inalterado nos dois modos, dentro do limite de tamanho do mapeamento. Primeiro, verifique se os digests coincidem. No Bash, estes comandos medem o comando inteiro, incluindo a inicialização da JVM, a abertura do arquivo, o mapeamento ou a alocação do buffer, o cálculo do hash e o fechamento do canal:
time java FileChecksum mapped example.txt &&
time java FileChecksum buffered example.txt
Substitua example.txt nos dois comandos pelo seu arquivo representativo. O
exemplo de três bytes serve para verificar a correção e não é útil para comparar velocidade. Repita
as medições, inverta a ordem e mantenha iguais o JDK, as opções da JVM, a entrada e a carga da
máquina. Compare a dispersão dos tempos decorridos, não apenas a execução mais rápida. Leituras
anteriores podem preencher o cache de arquivos do sistema operacional, então registre separadamente
as observações da primeira execução e das execuções repetidas; uma nova JVM não implica um cache de
arquivos frio. Esses tempos mostram quanto tempo este comando leva. Eles não preveem o desempenho de
um serviço de longa duração nem de uma carga de trabalho com acesso aleatório.
Boas práticas
O try-with-resources fecha o canal, mas fechar um canal não desfaz o mapeamento do buffer. O mapeamento continua válido até que o buffer seja coletado pelo garbage collector. Por isso, em uma aplicação de longa duração, mapeamentos repetidos podem se acumular antes de serem liberados. Este comando curto encerra a JVM depois de processar um arquivo; ele não demonstra como desfazer mapeamentos de forma determinística dentro de um serviço.
Não confunda baixo uso de heap com baixo uso total de memória. O mapeamento reserva espaço de endereçamento virtual, e as páginas do arquivo que foram acessadas consomem memória física. Este código evita um array no heap com o arquivo inteiro, mas a implementação do digest ainda pode copiar blocos internamente. O espaço de endereçamento disponível e os recursos do sistema podem impedir o mapeamento mesmo abaixo do limite de região da API.
Considerações sobre segurança de threads
Mantenha o exemplo em uma única thread. Embora canais de arquivo suportem uso concorrente,
buffers exigem sincronização quando compartilhados entre threads.
Mesmo um buffer somente leitura tem uma posição mutável, que digest.update()
avança. Dar a cada tarefa o próprio buffer e o próprio digest evita o compartilhamento desses
objetos mutáveis; isso não protege o arquivo subjacente contra outro escritor.
Casos de uso comuns
Para um checksum de passagem única, a leitura com buffer é um ponto de partida simples e não está sujeita ao limite de tamanho de uma única região. Vale investigar o mapeamento quando um parser consulta repetidamente registros por offset de bytes em um arquivo inalterado: um buffer mapeado oferece acesso indexado sem uma leitura explícita a cada consulta. Meça esse padrão de acesso separadamente antes de alterar uma implementação que já funciona. Uma comparação de checksums, por si só, não basta para comprovar que ele fica mais rápido.
