Cuando trabajamos con flujos de datos en Java, es común utilizar clases envolventes que modifican o mejoran el comportamiento de los streams básicos. Entre estas, BufferedInputStream y BufferedOutputStream juegan un papel fundamental para optimizar la lectura y escritura de datos mediante el uso de un buffer interno. Este buffer es una región de memoria, como un array, que almacena temporalmente datos para reducir la cantidad de accesos directos al disco o a la red, operaciones que suelen ser más lentas.
Para entender mejor cómo funciona BufferedInputStream, imaginemos que queremos leer datos byte a byte. Si cada llamada a read solicita directamente al sistema operativo un byte, estaremos haciendo muchas llamadas pequeñas y frecuentes, lo que resulta ineficiente, especialmente cuando el dispositivo de almacenamiento es un disco duro tradicional. Aunque los SSDs han mejorado mucho esta situación, sigue siendo preferible minimizar estas operaciones. BufferedInputStream envuelve otro InputStream y, cuando llamamos a read sobre él, en realidad realiza una lectura grande y almacena esos datos en un buffer interno, por ejemplo, un array con capacidad para 4000 bytes. Luego, cuando pedimos datos byte a byte, el BufferedInputStream nos los va entregando desde ese buffer, evitando así múltiples accesos lentos al disco o red.
Este mecanismo hace que las lecturas posteriores sean mucho más rápidas mientras queden datos en el buffer, ya que no se necesita volver a acceder al stream original hasta que el buffer se agote. De esta forma, podemos consumir los datos de manera eficiente, incluso si queremos leer pequeñas cantidades a la vez.
El funcionamiento de BufferedOutputStream es análogo pero aplicado a la escritura. En lugar de enviar cada byte directamente al destino final, BufferedOutputStream almacena los datos en un buffer interno. Cada llamada a write añade datos a este buffer, y solo cuando el buffer está lleno o cuando llamamos a flush, se envía toda la información acumulada de una sola vez al OutputStream real. Esto reduce la cantidad de operaciones de escritura, que también pueden ser costosas en términos de rendimiento.
Por ejemplo, si tenemos una función que escribe 256 bytes llamando a write en cada iteración, hacerlo directamente sobre un OutputStream puede ser ineficiente porque cada llamada implica una operación de escritura individual. En cambio, si envolvemos ese OutputStream con un BufferedOutputStream y escribimos sobre él, cada llamada a write solo añade datos al buffer interno. Cuando el buffer se llena o llamamos a flush, se realiza una única operación de escritura grande, lo que suele ser mucho más eficiente.
Es importante recordar que, al usar BufferedOutputStream, debemos llamar a flush al terminar de escribir para asegurarnos de que cualquier dato que quede en el buffer se envíe realmente al destino final. De lo contrario, podríamos perder información que aún está almacenada en memoria y no ha sido escrita.
Aunque en pruebas con volúmenes pequeños de datos la diferencia puede no ser perceptible, cuando trabajamos con grandes cantidades de información, el uso de BufferedInputStream y BufferedOutputStream puede mejorar significativamente el rendimiento de nuestras aplicaciones al optimizar las operaciones de entrada y salida.