1. Chunking — lectura de archivos por partes
Como ya comentamos en la lección anterior, el chunking permite trabajar con archivos por partes sin cargarlos por completo en memoria. Esto es especialmente importante cuando se trata de grandes volúmenes de datos. Si un archivo pesa 10 MB, normalmente no hay problema: se puede cargar sin dificultad y trabajar con él de cualquier manera. Pero ¿qué hacer si el archivo llega a 10 GB y solo hay 8 GB de RAM, con el navegador con decenas de pestañas y la IDE abiertas? Intentar leer un archivo así completo suele acabar trágicamente: OutOfMemoryError, cuelgue del programa y lágrimas del desarrollador.
Ejemplos reales de archivos tan grandes aparecen constantemente: los logs de servidores de un mes pueden ocupar decenas de gigabytes, los archivos CSV grandes contienen millones de líneas, y los vídeos, archivos comprimidos y volcados de bases de datos pueden ser aún mayores.
La idea principal sigue siendo la misma: no intentar «comerse al elefante de una vez», sino trabajar a trozos. Precisamente el chunking permite procesar de forma segura y eficiente estos datos dividiendo el archivo en partes manejables.
Una vez más sobre chunking
Chunk (trozo, bloque) es simplemente una parte del archivo de un tamaño determinado. En lugar de leer todo de una vez, leemos, por ejemplo, de 4 MB (o de 64 KB, o de 1 MB, según la situación).
Principio:
- Abrimos un flujo para leer el archivo.
- Creamos un búfer: un array de bytes de tamaño fijo.
- En un bucle leemos del archivo al búfer hasta llegar al final.
- Cada «trozo» se procesa por separado.
Ejemplo: copiar un archivo grande por trozos
Supongamos que tenemos un archivo enorme que hay que copiar. Escribamos un programa que lo haga como es debido.
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);
// Se puede añadir la salida del progreso o procesar los datos
}
}
System.out.println("¡Copia completada!");
}
}
Para trabajar con archivos en Java normalmente se usan los flujos estándar FileInputStream y FileOutputStream. Es una buena práctica usar un búfer de alrededor de 4 MB: es suficiente para discos modernos para que la lectura y la escritura sean eficientes. En el bucle, el programa lee trozos del archivo y los escribe inmediatamente en el archivo nuevo, sin intentar mantener todo el archivo en memoria.
Este enfoque permite ahorrar memoria RAM, evitar errores como OutOfMemoryError y trabajar con archivos prácticamente de cualquier tamaño, incluso si son de 100 GB o más.
2. Chunking para el procesamiento de datos
A menudo la tarea no es simplemente copiar un archivo, sino, por ejemplo, encontrar una determinada línea, contar ocurrencias, reemplazar algo, etc.
Ejemplo: búsqueda de una cadena en un archivo de texto grande
Si el archivo es de texto, es más cómodo usar flujos de caracteres y lectura línea a línea:
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("Se han encontrado " + count + " líneas con ERROR");
}
}
¿Por qué esto funciona incluso para archivos de gigabytes?
- BufferedReader lee el archivo por trozos (por defecto el búfer es de 8 KB, pero se puede aumentar).
- En memoria, en cada momento, solo se almacena una línea.
Tamaño del búfer: ¿cuál elegir?
Regla de oro: un búfer demasiado pequeño implica muchas operaciones de E/S al disco; uno demasiado grande malgasta memoria.
- Para HDD/SSD modernos suele funcionar bien un búfer de 64 KB – 4 MB.
- Para SSD muy rápidos o almacenamiento en red — se puede usar más (8–16 MB).
- Para archivos de texto — se puede aumentar el búfer en BufferedReader.
¡Experimenta! Mide el tiempo de ejecución del programa con distintos tamaños de búfer. A veces aumentarlo aporta una aceleración de 2–3 veces, y a veces — casi no influye.
3. Memory-mapped files (mapeo del archivo en memoria)
¿Qué es exactamente?
El memory mapping es una forma de «mapear» un archivo directamente en la memoria del proceso mediante mecanismos del sistema operativo. En Java se utiliza la clase MappedByteBuffer del paquete java.nio. Es como si el archivo se convirtiera en un gran array de bytes con el que se puede trabajar directamente, sin lectura y escritura explícitas de cada trozo.
Este enfoque es especialmente útil para trabajar con archivos muy grandes. El sistema operativo carga en memoria las partes necesarias del archivo, y puedes acceder a cualquier punto del archivo como si fuera un array normal. Los memory-mapped files proporcionan una alta velocidad de acceso aleatorio. Por ejemplo, cuando necesitas leer rápidamente trozos del archivo de diferentes sitios sin cargarlo por completo.
¿Cómo se ve en el 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 — tamaño de un mapping
long position = 0;
while (position < fileSize) {
long size = Math.min(chunkSize, fileSize - position);
MappedByteBuffer buffer = channel.map(FileChannel.MapMode.READ_ONLY, position, size);
// Leemos datos del buffer como si fuera un array
for (int i = 0; i < size; i++) {
byte b = buffer.get(i);
// Procesamiento del byte (por ejemplo, buscar un valor concreto)
}
position += size;
}
}
System.out.println("¡Lectura mediante memory mapping completada!");
}
}
RandomAccessFile y FileChannel permiten acceder al archivo a bajo nivel. La llamada a channel.map mapea una sección del archivo en memoria. El acceso a los datos es a través del búfer MappedByteBuffer.
¿Cuáles son las ventajas del memory mapping?
- Muy rápido para acceso aleatorio a distintas partes del archivo.
- Permite trabajar con archivos más grandes que la memoria RAM disponible (el SO carga las páginas necesarias).
- Se usa en bases de datos modernas, índices y grandes logs.
¿Y las desventajas?
- No siempre es adecuado para escritura (especialmente en sistemas de archivos en red).
- Limitaciones de tamaño por mapping (normalmente hasta 2 GB por mapping en JVM de 32 bits).
- Si se olvida cerrar el archivo, puede producirse un «bloqueo» del archivo (especialmente en Windows).
- No todas las operaciones con archivos se aceleran — si solo necesitas leer el archivo secuencialmente, un búfer normal a menudo no es peor.
4. Ejemplos prácticos
Ejemplo 1: búsqueda de una subcadena en un archivo grande mediante memory mapping
Supongamos que tenemos un archivo de 10 GB y queremos encontrar en él una secuencia concreta de bytes (por ejemplo, la cadena "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 en la posición " + (position + i));
// Se puede detener la búsqueda o continuar
}
}
position += size;
}
}
}
}
Ten en cuenta:
Si la subcadena puede «romperse» entre dos chunks, hay que prever un solapamiento entre chunks de la longitud de la secuencia buscada.
5. Matices útiles
¿Cuándo usar chunking y cuándo memory mapping?
- Chunking — enfoque universal para cualquier archivo (texto, binarios, logs, archivos comprimidos). Funciona bien para procesamiento secuencial.
- Memory mapping — supereficiente para acceso aleatorio, trabajo con índices grandes, bases de datos, búsquedas rápidas en archivos enormes.
Si no sabes qué elegir — empieza por chunking. El memory mapping es una herramienta potente, pero más «de bajo nivel», que requiere cuidado.
Recomendaciones
- Usa try-with-resources para cerrar automáticamente flujos y canales.
- No abras demasiados archivos a la vez: el SO tiene límites en el número de descriptores abiertos.
- No mapees trozos excesivamente grandes — puede provocar errores (especialmente en JVM de 32 bits).
- Para procesamiento en paralelo se puede dividir el archivo en chunks y procesarlos en hilos separados (pero es importante no «saturar» el disco ni exceder la memoria).
6. Errores típicos al trabajar con archivos grandes
Error n.º 1: intentar cargar todo un archivo grande en memoria.
Un problema muy frecuente — especialmente en principiantes. Si el archivo es mayor de 1–2 GB, usa chunking o lectura línea a línea; de lo contrario, el programa fallará con OutOfMemoryError.
Error n.º 2: un búfer demasiado pequeño.
Un búfer de 512 bytes no es una optimización, sino un suicidio para el rendimiento. Usa búferes de 64 KB o mayores.
Error n.º 3: olvidar cerrar el flujo o el canal.
El descriptor de archivo se quedará colgado; el archivo no se eliminará o no se liberará hasta reiniciar la JVM. Usa try-with-resources.
Error n.º 4: uso incorrecto del memory mapping.
Si otro proceso modifica el archivo durante el mapping, puedes obtener datos inconsistentes o un error. No uses memory mapping para archivos que cambian con frecuencia.
Error n.º 5: no tener en cuenta el solapamiento de chunks al buscar subcadenas.
Si la cadena buscada puede quedar «en el borde» de dos chunks, es obligatorio hacer un solapamiento entre chunks de la longitud de esa cadena.
GO TO FULL VERSION