CodeGym /Cursos /JAVA 25 SELF /NIO Channels e ByteBuffer

NIO Channels e ByteBuffer

JAVA 25 SELF
Nível 41 , Lição 1
Disponível

1. Introdução aos NIO Channels

No Java IO clássico (java.io), tudo funciona pelo princípio “uma thread — um arquivo ou recurso”. Assim que a leitura ou escrita começa, a thread é bloqueada e aguarda a conclusão da operação. Para casos simples isso é conveniente, mas em sistemas com alta carga essa abordagem vira um gargalo: se houver milhares de conexões, milhares de threads ficam ocupadas esperando.

No NIO (New IO) a abordagem é diferente. Aqui o I/O pode ser não bloqueante, e a thread não precisa ficar ociosa. Enquanto alguns dados ainda estão chegando, ela pode alternar para outra tarefa. Isso permite atender a uma quantidade enorme de conexões com apenas algumas threads.

A diferença também aparece nos detalhes. No IO “antigo”, o trabalho gira em torno de streams que leem e escrevem bytes ou caracteres, mas sempre bloqueiam durante as operações. No NIO, os conceitos centrais são os canais (Channels) e os buffers (Buffers). Eles permitem implementar I/O não bloqueante (importante para servidores), além de aplicar zero-copy, quando os dados são transferidos diretamente, evitando cópias desnecessárias para buffers da JVM.

Comparação: streams (Streams) vs canais (Channels)

Streams (InputStream/OutputStream):

  • Leem/escrevem bytes um a um ou em arrays.
  • Não há controle direto sobre a posição no arquivo.
  • Não são eficientes com arquivos muito grandes.

Canais (Channel):

  • Leem/escrevem dados via buffers (Buffer).
  • Permitem controlar a posição (incluindo acesso randômico).
  • Suportam assincronia e modo não bloqueante.
  • Permitem usar zero-copy para cópias muito rápidas.

2. FileChannel e SeekableByteChannel

Leitura e escrita de dados usando buffers

FileChannel é o canal principal para trabalhar com arquivos. Ele pode ser obtido de FileInputStream, FileOutputStream ou via NIO.2 — Files.newByteChannel (retorna SeekableByteChannel).

Exemplo: leitura de arquivo com FileChannel e ByteBuffer

import java.io.RandomAccessFile;
import java.nio.ByteBuffer;
import java.nio.channels.FileChannel;

public class FileChannelReadExample {
    public static void main(String[] args) throws Exception {
        try (RandomAccessFile file = new RandomAccessFile("data.txt", "r");
             FileChannel channel = file.getChannel()) {

            ByteBuffer buffer = ByteBuffer.allocate(1024); // buffer de 1 KB

            int bytesRead = channel.read(buffer); // lemos para o buffer
            while (bytesRead != -1) {
                buffer.flip(); // alternamos o buffer para o modo de leitura
                while (buffer.hasRemaining()) {
                    System.out.print((char) buffer.get());
                }
                buffer.clear(); // limpamos o buffer para a próxima leitura
                bytesRead = channel.read(buffer);
            }
        }
    }
}

Escrita em arquivo:

try (RandomAccessFile file = new RandomAccessFile("output.txt", "rw");
     FileChannel channel = file.getChannel()) {

    ByteBuffer buffer = ByteBuffer.wrap("Hello, NIO!\n".getBytes());
    channel.write(buffer);
}

NIO.2: abrindo um canal via Files.newByteChannel

import java.nio.file.*;
import java.nio.channels.SeekableByteChannel;
import static java.nio.file.StandardOpenOption.*;

Path path = Paths.get("data.txt");
try (SeekableByteChannel ch = Files.newByteChannel(path, READ)) {
    ByteBuffer buf = ByteBuffer.allocate(256);
    ch.read(buf);
}

Posicionamento (position()) e truncamento (truncate())

  • position() — permite obter ou definir a posição atual no arquivo (análogo a um “cursor”).
  • truncate(long size) — corta o arquivo para o tamanho especificado.
channel.position(100);   // mover para o byte 100
channel.truncate(1024);  // truncar o arquivo para 1 KB

Acesso direto e posicional a arquivos

  • Acesso direto: é possível ler/escrever em qualquer ponto do arquivo, não apenas de forma sequencial.
  • Acesso posicional: é possível ler/escrever dados em uma posição específica sem alterar a posição atual do canal.
ByteBuffer buffer = ByteBuffer.allocate(4);
channel.read(buffer, 128);   // ler 4 bytes a partir da posição 128, sem alterar channel.position()

3. ByteBuffer: como funciona

Parâmetros principais: capacity, limit, position, mark

  • capacity — tamanho máximo do buffer (definido na criação).
  • limit — limite até onde se pode ler/escrever (por padrão igual a capacity).
  • position — posição atual (para onde escrevemos/de onde lemos).
  • mark — uma “marca” que pode ser definida e, depois, retornada.

Ciclo de vida do buffer:

  1. Escrevemos dados no buffer (por exemplo, lendo do canal read()).
  2. flip() — alterna o buffer para o modo de leitura (position = 0, limit = valor atual de position).
  3. Lemos os dados do buffer (get()).
  4. clear() — limpa o buffer para a próxima escrita (position = 0, limit = capacity).

Exemplo:

ByteBuffer buffer = ByteBuffer.allocate(8);
buffer.put((byte) 42);
buffer.flip();                // agora é possível ler
byte value = buffer.get();    // 42
buffer.clear();               // pronto para nova escrita

Criação de buffers: allocate() vs allocateDirect()

O ByteBuffer tem duas maneiras principais de criar um buffer, e a diferença entre elas é perceptível na prática. O método allocate() aloca o buffer no heap da JVM: é criado rapidamente e serve para a maioria dos casos, mas em I/O nativo podem ocorrer cópias adicionais entre o heap e a memória do SO.

O método allocateDirect() aloca memória fora do heap da JVM (na “native memory”). Esse buffer é mais caro de criar e mais difícil de gerenciar, mas ao ler/gravar arquivos grandes ou em operações de rede costuma ser mais rápido por evitar cópias desnecessárias.

A ideia é simples: se o desempenho com grandes volumes é crítico — use buffers diretos. Para operações pequenas e frequentes, o custo de criá-los pode superar o benefício.

ByteBuffer directBuffer = ByteBuffer.allocateDirect(4096);

4. Operações de alto desempenho: transferTo() e transferFrom()

Métodos transferTo() e transferFrom()

A classe FileChannel tem dois métodos que permitem trabalhar no princípio de “cópia zero” — transferTo() e transferFrom(). A ideia é que os dados podem ser transferidos diretamente entre canais de arquivo ou, por exemplo, entre um arquivo e a rede. A JVM quase não participa: a operação é realizada pelo SO, e os buffers dentro do Java não são mexidos.

Como resultado, copiar arquivos grandes fica visivelmente mais rápido: menos cópias, menos comutações entre user space e kernel space e menor carga na CPU.

Exemplo: cópia de arquivo via zero-copy

import java.nio.channels.FileChannel;
import java.nio.file.*;

public class ZeroCopyExample {
    public static void main(String[] args) throws Exception {
        try (FileChannel src = FileChannel.open(Paths.get("input.bin"), StandardOpenOption.READ);
             FileChannel dst = FileChannel.open(Paths.get("output.bin"), StandardOpenOption.CREATE, StandardOpenOption.WRITE)) {

            long size = src.size();
            long transferred = src.transferTo(0, size, dst);
            System.out.println("Bytes copiados: " + transferred);
        }
    }
}

Quando o zero-copy realmente funciona?

  • Ao copiar entre arquivos no mesmo disco.
  • Ao enviar arquivos pela rede (por exemplo, via SocketChannel).
  • Quando o SO oferece suporte a zero-copy (Linux, macOS, Windows — oferecem).

Vantagens:

  • Mínimas cópias: os dados não passam pelos buffers da JVM.
  • Alta velocidade: menos trocas de contexto e menor carga da CPU.
  • Menos memória: não são necessários buffers grandes no lado do usuário.

Exemplo: cópia de arquivo “em uma única linha”

Files.copy(Paths.get("input.bin"), Paths.get("output.bin"), StandardCopyOption.REPLACE_EXISTING);
// Por baixo dos panos pode usar zero-copy, se possível

5. Erros comuns

Erro nº 1: esqueceu flip() antes de ler do buffer. Depois de escrever no buffer, chame flip(); caso contrário, a leitura não funcionará como esperado: position/limit permanecerão no “modo de escrita”.

Erro nº 2: usar allocateDirect() para operações pequenas. Buffers diretos são bons para grandes volumes, mas para requisições pequenas sua criação é injustificadamente cara. Por padrão, prefira allocate().

Erro nº 3: não fechar o canal. Sempre use try-with-resources com canais e streams para evitar vazamento de descritores.

Erro nº 4: confundir position/limit/capacity. Antes de ler/escrever, verifique em qual modo o buffer está: após a escrita é necessário flip(), após a leitura, para nova escrita — clear() ou compact().

Erro nº 5: esperar que o zero-copy “sempre funcione”. Em algumas configurações (dispositivos diferentes/sistemas de arquivos distintos/flags especiais), o zero-copy pode não estar disponível — nesse caso ocorrerá a cópia padrão, e o desempenho será diferente.

Comentários
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION