1. Cuándo ayuda el multihilo
El multihilo es necesario cuando hay mucho trabajo que puede hacerse al mismo tiempo. Por ejemplo, si hay que procesar decenas de archivos — copiarlos, recalcularlos o analizarlos — es más sencillo encargar partes distintas a hilos diferentes que hacerlo todo de forma secuencial. Es como si, en lugar de un solo amigo revisando tu archivo de fotos, llamaras a cinco: el trabajo avanzará más rápido y será más ameno.
Es especialmente útil cuando se trata de procesamiento por lotes de archivos, descarga o copia de archivos grandes por partes, o cuando, tras leer los datos, hay que calcular algo en paralelo para distintas partes.
Pero el multihilo no siempre ayuda. Si tienes un solo archivo pequeño, lanzar una decena de hilos para él no tiene sentido. Si el disco o la red ya están saturados, nuevos hilos solo ralentizarán el proceso. Y si varios hilos escriben simultáneamente en el mismo archivo sin sincronización, puedes obtener un auténtico caos con datos corruptos.
En pocas palabras, el multihilo es una herramienta. Como un martillo: puedes clavar un clavo o darte en el dedo. Lo importante es saber cuándo y cómo usarlo.
2. Herramientas de Java para la E/S multihilo
Ya sabes que Java tiene varias formas de ejecutar tareas en paralelo:
- Clásico Thread — creación manual de hilos.
- Pool de hilos con ExecutorService — una forma moderna, flexible y cómoda.
- CompletableFuture y streams paralelos (Stream API) — para tareas avanzadas (los veremos con más detalle en próximas lecciones).
Empecemos por lo más simple: procesar varios archivos en hilos diferentes.
Ejemplo 1: Thread clásico
public class FileCopyTask extends Thread {
private final Path source;
private final Path target;
public FileCopyTask(Path source, Path target) {
this.source = source;
this.target = target;
}
@Override
public void run() {
try {
Files.copy(source, target, StandardCopyOption.REPLACE_EXISTING);
System.out.println("Archivo copiado: " + source);
} catch (IOException e) {
System.err.println("Error al copiar " + source + ": " + e.getMessage());
}
}
}
// Lanzar varias copias en hilos separados
List<Path> filesToCopy = List.of(
Path.of("log1.txt"), Path.of("log2.txt"), Path.of("log3.txt")
);
for (Path file : filesToCopy) {
new FileCopyTask(file, Path.of("backup_" + file.getFileName())).start();
}
Ventajas: Simple y fácil de entender.
Desventajas: Gestionar muchos hilos manualmente es incómodo; no hay control sobre cuántos hilos trabajan a la vez.
Ejemplo 2: ExecutorService — pool de hilos
ExecutorService te permite delegar tareas a un pool de hilos, que decide por sí mismo cuántos hilos usar simultáneamente.
import java.util.concurrent.*;
public class MultiFileCopier {
public static void main(String[] args) throws InterruptedException {
ExecutorService executor = Executors.newFixedThreadPool(4); // máximo 4 hilos
List<Path> filesToCopy = List.of(
Path.of("log1.txt"), Path.of("log2.txt"), Path.of("log3.txt")
);
for (Path file : filesToCopy) {
executor.submit(() -> {
try {
Files.copy(file, Path.of("backup_" + file.getFileName()), StandardCopyOption.REPLACE_EXISTING);
System.out.println("Copiado: " + file);
} catch (IOException e) {
System.err.println("Error: " + file + " " + e.getMessage());
}
});
}
executor.shutdown(); // no aceptamos más tareas
executor.awaitTermination(1, TimeUnit.MINUTES); // esperamos a que finalicen todas las tareas
}
}
Ventajas:
- Fácil de escalar (puedes fijar el número de hilos necesario).
- Cómodo para controlar la finalización de tareas (métodos shutdown(), awaitTermination(...)).
- Adecuado para procesar cientos y miles de archivos.
3. Problemas y limitaciones de la E/S multihilo
Contención por recursos
Si intentas leer o escribir el mismo archivo desde varios hilos simultáneamente sin sincronización, el resultado será impredecible. Es como si dos personas escribieran a la vez en la misma página de un libro: saldrá un batiburrillo. Para coordinar, usa, por ejemplo, synchronized, bloqueos explícitos o un hilo dedicado a la escritura.
Limitaciones del sistema de archivos y del SO
- No todos los sistemas de archivos soportan bien la escritura simultánea en un mismo archivo.
- El sistema operativo puede limitar la cantidad de archivos abiertos simultáneamente.
- Un disco duro (especialmente un HDD) funciona mal con un gran número de accesos aleatorios.
Sincronización al escribir en un recurso compartido
Si varios hilos escriben en un archivo (por ejemplo, un log), usa obligatoriamente sincronización (por ejemplo, con synchronized, bloqueos o hilos especiales de escritura).
Ineficiencia con archivos pequeños
Para archivos pequeños, el overhead de crear hilos y conmutar entre ellos puede ser mayor que la ganancia del paralelismo.
4. Ejemplos prácticos
Copia de archivos en paralelo
Supongamos que tenemos una carpeta con muchos logs que hay que copiar a un directorio de archivo.
import java.nio.file.*;
import java.util.List;
import java.util.concurrent.*;
public class ParallelFileCopier {
public static void main(String[] args) throws InterruptedException {
ExecutorService executor = Executors.newFixedThreadPool(4);
List<Path> filesToCopy = List.of(
Path.of("log1.txt"), Path.of("log2.txt"), Path.of("log3.txt")
// ... añade tantos archivos como quieras
);
for (Path file : filesToCopy) {
executor.submit(() -> {
try {
Path target = Path.of("archive", file.getFileName().toString());
Files.copy(file, target, StandardCopyOption.REPLACE_EXISTING);
System.out.println("Copiado: " + file);
} catch (IOException e) {
System.err.println("Error: " + file + " " + e.getMessage());
}
});
}
executor.shutdown();
executor.awaitTermination(10, TimeUnit.MINUTES);
}
}
Comentario:
- Usamos un pool de 4 hilos — suele ser suficiente para cargar el disco sin sobrecargar el sistema.
- Para 1000 archivos se puede aumentar el pool a 8, pero no conviene hacerlo demasiado grande.
Procesamiento paralelo de líneas de un archivo con Stream API
Desde Java 8 se pueden usar streams paralelos para procesar el contenido de un archivo:
import java.nio.file.*;
import java.io.IOException;
public class ParallelLineProcessing {
public static void main(String[] args) throws IOException {
Path path = Path.of("biglog.txt");
// Files.lines devuelve Stream<String> — flujo de líneas del archivo
Files.lines(path)
.parallel() // lo convertimos en flujo paralelo
.filter(line -> line.contains("ERROR"))
.forEach(line -> System.out.println("Error: " + line));
}
}
Importante:
- Los streams paralelos aceleran el procesamiento si requiere muchos cálculos (CPU-bound), no el propio proceso de lectura (I/O-bound).
- Si el procesamiento de cada línea es simple (por ejemplo, solo imprimir con System.out.println), puede que no haya mejora.
Lectura/escritura de distintas partes de un mismo archivo grande
Java permite leer o escribir distintas secciones de un archivo al mismo tiempo con FileChannel y métodos de posicionamiento. Es un nivel avanzado, pero el principio es sencillo: cada hilo trabaja con su propio fragmento del archivo.
import java.nio.channels.FileChannel;
import java.nio.file.*;
import java.io.*;
import java.nio.ByteBuffer;
public class FileChunkReader implements Runnable {
private final Path path;
private final long position;
private final long size;
public FileChunkReader(Path path, long position, long size) {
this.path = path;
this.position = position;
this.size = size;
}
@Override
public void run() {
try (FileChannel channel = FileChannel.open(path, StandardOpenOption.READ)) {
ByteBuffer buffer = ByteBuffer.allocate((int) size);
channel.read(buffer, position);
System.out.println("Leído fragmento desde la posición " + position + " con tamaño " + size);
// Aquí se puede procesar el buffer
} catch (IOException e) {
System.err.println("Error al leer el fragmento: " + e.getMessage());
}
}
}
// Ejemplo de ejecución: leemos el archivo en trozos de 1 MB con 4 hilos
Path file = Path.of("bigdata.bin");
long fileSize = Files.size(file);
long chunkSize = 1024 * 1024; // 1 MB
int chunks = (int) Math.ceil((double) fileSize / chunkSize);
ExecutorService executor = Executors.newFixedThreadPool(4);
for (int i = 0; i < chunks; i++) {
long position = i * chunkSize;
long size = Math.min(chunkSize, fileSize - position);
executor.submit(new FileChunkReader(file, position, size));
}
executor.shutdown();
executor.awaitTermination(10, TimeUnit.MINUTES);
Comentario:
- Cada hilo lee su propio fragmento del archivo, sin molestar a los demás.
- Este enfoque se usa, por ejemplo, en torrents y gestores de descargas.
Sincronización al escribir en un archivo común
Si varios hilos escriben en el mismo archivo (por ejemplo, un log), hay que sincronizar el acceso para no obtener un «batiburrillo» de líneas:
import java.io.*;
public class SafeLogger {
private final Writer writer;
public SafeLogger(String filename) throws IOException {
this.writer = new BufferedWriter(new FileWriter(filename, true));
}
public synchronized void log(String message) throws IOException {
writer.write(message);
writer.write(System.lineSeparator());
writer.flush();
}
public void close() throws IOException {
writer.close();
}
}
Comentario:
- El método log está marcado como synchronized para que solo un hilo escriba en el archivo en cada momento.
- Funciona, pero con muchos hilos puede convertirse en un cuello de botella — mejor escribir en archivos distintos y luego combinarlos.
5. Cuándo no conviene usar multihilo
El multihilo es tentador: ¡más hilos, todo volará! Pero en la práctica no siempre es así. Si procesas un par de archivos pequeños, es más simple y fiable hacerlo de forma secuencial. El tiempo que inviertas en lanzar hilos y coordinarlos puede no compensar.
A veces el problema ni siquiera está en el disco, sino en la red — entonces añadir hilos no acelerará nada, porque el cuello de botella está en otra parte. Otra trampa es la escritura paralela en el mismo archivo. Si tienes poca experiencia con sincronización, mejor ni lo intentes: es muy probable que termines con datos corruptos.
Y por último, si tu disco o sistema de archivos no se llevan bien con decenas de hilos accediendo a la vez, el multihilo no te salvará; solo empeorará la situación.
En pocas palabras, si te parece que «cuantos más hilos, mejor», la mayoría de las veces no es así. A veces un único hilo tranquilo hace el trabajo de forma más limpia, rápida y fiable que una decena apresurada.
6. Breve introducción a FileChannel para tareas avanzadas
FileChannel del paquete java.nio.channels es una herramienta de bajo nivel para trabajar con archivos que permite leer y escribir datos en posiciones arbitrarias. Esto posibilita, por ejemplo, la descarga paralela o el procesamiento de archivos grandes por partes.
Ejemplo:
try (FileChannel channel = FileChannel.open(Path.of("bigfile.bin"), StandardOpenOption.READ)) {
ByteBuffer buffer = ByteBuffer.allocate(1024);
long position = 0;
int bytesRead = channel.read(buffer, position); // leemos 1024 bytes desde la posición 0
// procesar buffer
}
Importante:
- FileChannel no está sincronizado — si varios hilos trabajan con el mismo canal, hay que implementar la sincronización manualmente.
- Para trabajo en paralelo es más sencillo abrir un canal separado por cada hilo.
7. Errores típicos en la E/S multihilo
Error n.º 1: Acceso no sincronizado a un mismo archivo para escritura.
Como resultado — datos corruptos, caracteres raros, a veces el archivo ni siquiera se puede leer. Sincroniza siempre el acceso o escribe en archivos separados.
Error n.º 2: Demasiados hilos.
Si abres 1000 hilos para copiar 1000 archivos, tu equipo puede enfadarse (OutOfMemoryError, lentitud, caídas). Usa un pool de hilos (ExecutorService) y limita su cantidad.
Error n.º 3: No se cierran flujos/archivos.
Cada flujo abierto es un recurso del SO. Si no se cierran, puedes obtener el error «Too many open files». Usa try-with-resources o no olvides llamar a close().
Error n.º 4: Finalización prematura del programa.
Si no esperas a que terminen todos los hilos (por ejemplo, no llamas a executor.awaitTermination(...)), el programa puede finalizar antes de que todos los archivos se copien.
Error n.º 5: Escritura paralela en la misma zona del archivo sin respetar las posiciones.
Si varios hilos escriben en la misma región del archivo, los datos se mezclarán. Para escribir por posiciones, usa canales y una separación clara de rangos.
GO TO FULL VERSION