Lee archivos en Java con mapeo de memoria
FileChannel.map() de Java permite leer los bytes de un archivo mediante un
MappedByteBuffer. Para probarlo con una tarea concreta, el programa siguiente calcula
la suma de comprobación SHA-256 de un archivo mediante mapeo de memoria o lecturas con búfer.
Ambos modos procesan los mismos bytes, por lo que puedes comprobar que los resultados sean correctos
antes de comparar su rendimiento con tus archivos.
Conceptos básicos de los archivos mapeados en memoria
Un mapeo expone una región de un archivo a través de la memoria virtual. Acceder a sus bytes puede requerir que el sistema operativo cargue páginas del archivo en la RAM; mapear un archivo no significa que todos sus bytes ya estén residentes. La documentación de Java sobre búferes directos describe los búferes mapeados como búferes directos cuyo contenido reside fuera del heap habitual de Java.
Este ejemplo calcula el hash de los bytes sin decodificarlos. El texto, los caracteres UTF-8 y los
datos binarios siguen la misma ruta. No se valida la codificación de caracteres ni se normalizan los
saltos de línea. Si, en cambio, decodificas un mapeo completo en un
String, los caracteres decodificados y la cadena resultante siguen necesitando
espacio en el heap proporcional al tamaño del texto. El mapeo no elimina esa asignación.
Limitaciones importantes
Usa un archivo regular que no cambie y un JDK 21 de 64 bits. Los comandos siguientes usan Bash y se probaron en Linux con OpenJDK 21.0.12.1; aquí no se ha probado el comportamiento de los archivos en Windows ni en macOS.
La versión de tres argumentos de
FileChannel.map()
acepta como máximo Integer.MAX_VALUE bytes por región: 2.147.483.647 bytes, o 2 GiB menos
un byte. El modo con mapeo que se muestra a continuación rechaza los archivos más grandes. Su
contraparte con búfer puede leer archivos más grandes con un búfer reutilizable de 64 KiB. Usar varios
mapeos es otra opción, pero este ejemplo utiliza una región para que su ciclo de vida y su límite de
tamaño queden claros.
No modifiques ni trunques el archivo de entrada durante ninguna de las dos lecturas. Un mapeo de solo
lectura no congela el archivo, y truncarlo puede dejar inaccesibles los bytes mapeados, como explica
el contrato de MappedByteBuffer.
Ninguno de los modos proporciona una instantánea de un archivo en el que otro proceso está
escribiendo.
Uso de Java NIO para archivos mapeados en memoria
Guarda este código como FileChecksum.java en el directorio de trabajo que prefieras.
No necesita ninguna dependencia aparte del 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)
consume los bytes entre la posición y el límite del búfer. En el modo con búfer,
flip() expone solo los bytes que se acaban de leer, incluido un último
fragmento corto, y clear() prepara el búfer para reutilizarlo.
La rama para archivos vacíos calcula el resumen sin crear un mapeo.
Crea un archivo de entrada de tres bytes que contenga abc, sin un salto
de línea al final, y luego compila y ejecuta ambos modos. La opción
noclobber del subshell impide sobrescribir un
example.txt existente. La compilación reemplaza
FileChecksum.class en este directorio de trabajo; las ejecuciones que calculan la suma de
comprobación solo leen la entrada y escriben en la salida estándar.
(set -o noclobber; printf 'abc' > example.txt) &&
javac FileChecksum.java &&
java FileChecksum mapped example.txt &&
java FileChecksum buffered example.txt
Ambos comandos deberían imprimir esta suma de comprobación:
ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad
Para reutilizar un archivo existente, omite su creación y pasa su ruta a cualquiera de los modos;
pon entre comillas las rutas que contengan espacios. Con un archivo vacío, la ejecución se completa
correctamente e imprime
e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855.
Los archivos inexistentes, los argumentos no válidos y los mapeos que superan el límite de tamaño
provocan una excepción: Java escribe el diagnóstico en la salida de error estándar y termina con un
estado distinto de cero. El programa no imprime una suma de comprobación si falla la lectura.
Consideraciones de rendimiento
El mapeo tiene costos de preparación. La
documentación de FileChannel
señala que leer una pequeña cantidad de datos de forma convencional puede costar menos que crear un
mapeo. El cálculo secuencial de una suma de comprobación también dedica tiempo a calcular el hash,
por lo que un método de acceso a archivos más rápido podría influir poco en el tiempo total de
ejecución.
Usa el mismo archivo representativo, sin cambios, para ambos modos y dentro del límite de tamaño del mapeo. Primero comprueba que los resúmenes coincidan. En Bash, estos comandos miden el comando completo, incluidos el inicio de la JVM, la apertura del archivo, el mapeo o la asignación del búfer, el cálculo del hash y el cierre del canal:
time java FileChecksum mapped example.txt &&
time java FileChecksum buffered example.txt
Reemplaza example.txt en ambos comandos por tu archivo representativo. El ejemplo
de tres bytes sirve para comprobar que el resultado sea correcto, no para hacer una comparación útil
de velocidad. Repite las mediciones, invierte el orden y mantén iguales el JDK, las opciones de la
JVM, la entrada y la carga del equipo. Compara la dispersión de los tiempos transcurridos, no solo
la ejecución más rápida. Las lecturas anteriores pueden poblar la caché de archivos del sistema
operativo, así que informa por separado las observaciones de la primera ejecución y de las repetidas;
una JVM nueva no implica una caché de archivos fría. Estas mediciones indican cuánto tarda este
comando. No predicen el rendimiento de un servicio de larga duración ni de una carga de trabajo de
acceso aleatorio.
Buenas prácticas
Try-with-resources cierra el canal, pero cerrar un canal no deshace el mapeo de su búfer. El mapeo sigue siendo válido hasta que el recolector de basura recoge el búfer. Por lo tanto, en una aplicación de larga duración, los mapeos repetidos pueden acumularse antes de que se liberen sus recursos. Este breve comando termina su JVM después de procesar un archivo; no demuestra cómo deshacer mapeos de forma determinista dentro de un servicio.
Evita equiparar un uso bajo del heap con un uso bajo de memoria total. El mapeo reserva espacio de direcciones virtuales, y las páginas del archivo a las que se accede consumen memoria física. Este código evita crear un array en el heap que contenga el archivo completo, pero la implementación del resumen aún puede copiar fragmentos internamente. El espacio de direcciones disponible y los recursos del sistema pueden impedir el mapeo incluso por debajo del límite por región de la API.
Consideraciones de seguridad entre hilos
Mantén el ejemplo con un solo hilo. Aunque los canales de archivos admiten el uso concurrente,
los búferes requieren sincronización cuando se comparten entre hilos.
Incluso un búfer de solo lectura tiene una posición mutable, que
digest.update() hace avanzar. Dar a cada tarea su propio búfer y objeto de resumen evita
compartir esos objetos mutables; no protege el archivo subyacente de otro proceso que escriba en él.
Casos de uso comunes
Para una suma de comprobación de una sola pasada, la lectura con búfer es un punto de partida sencillo y no tiene el límite de tamaño de una sola región. Vale la pena investigar el mapeo cuando un analizador consulta repetidamente registros por su desplazamiento en bytes dentro de un archivo que no cambia: un búfer mapeado ofrece acceso indexado sin una lectura explícita por cada consulta. Mide ese patrón de acceso por separado antes de cambiar una implementación que funciona. Una comparación de sumas de comprobación por sí sola no permite determinar si ese patrón será más rápido.
