Programação Java

Java I/O e NIO.2: streams, Readers, Path, Files, channels e buffers

Intermediário9 min de leituraAtualizado

Esta página foi traduzida do inglês com a ajuda de IA e conferida com o original. Ler a versão em inglês

O Java tem duas APIs de arquivos que convivem: os streams e readers originais do java.io e a API NIO.2 de Path/Files, que substituiu o java.io.File. Este guia explica o modelo de streams de bytes e de caracteres e seus decorators, como ler e gravar arquivos de forma idiomática com Files, como percorrer, monitorar e copiar diretórios, quando channels, buffers ou arquivos mapeados em memória valem a pena e onde encodings e gerenciamento de recursos dão errado. Você vai saber escolher a API certa e usá-la sem vazar descritores nem corromper texto.

O que você vai aprender

  • Explicar a diferença entre streams de bytes, streams de caracteres e decorators de buffer
  • Ler, gravar, copiar, mover e excluir arquivos com java.nio.file.Files e Path
  • Percorrer árvores de diretórios e reagir a mudanças no sistema de arquivos com Files.walk e WatchService
  • Tratar encodings de caracteres de forma explícita e evitar surpresas com o charset padrão da plataforma
  • Decidir quando channels, ByteBuffer e arquivos mapeados em memória compensam a complexidade

Ler e gravar arquivos é onde um programa encontra o sistema operacional, e o JDK expõe essa fronteira por meio de duas gerações de API. O java.io (1996) oferece streams e readers: componíveis, simples e ainda a base de toda biblioteca de rede e de compactação. O java.nio.file, conhecido como NIO.2 (Java 7), oferece Path e Files, um modelo de verdade do sistema de arquivos, com atributos, links, percurso de diretórios e notificação de mudanças. Código moderno usa Files para arquivos e as abstrações de stream para tudo o que flui. Este guia cobre as duas, junto com as regras de encoding e de gerenciamento de recursos que separam um I/O robusto de um código que funciona até dar de cara com um sistema de arquivos real. O código foi escrito para o Java 21.

O modelo de streams do java.io

O java.io tem duas hierarquias paralelas. Os streams de bytes (InputStream, OutputStream) transportam bytes brutos e são a ferramenta certa para dados binários. Os streams de caracteres (Reader, Writer) transportam texto decodificado. A ponte entre eles é um charset: o InputStreamReader decodifica bytes em caracteres, e o OutputStreamWriter codifica no sentido inverso.

Cada hierarquia tem origens e destinos (FileInputStream, ByteArrayOutputStream, Socket.getInputStream()) e decorators, que envolvem outro stream para acrescentar comportamento. É o padrão Decorator em seu habitat natural:

DecoratorAcrescenta
BufferedInputStream / BufferedReaderUm buffer em memória, para que o sistema operacional seja chamado uma vez por bloco em vez de uma vez por byte; o BufferedReader acrescenta readLine()
DataInputStream / DataOutputStreamTipos primitivos em um formato binário portável
ObjectInputStream / ObjectOutputStreamSerialização Java (evite para qualquer coisa que atravesse uma fronteira de confiança)
GZIPInputStream / InflaterInputStreamDescompactação
PrintWriter / PrintStreamSaída de texto formatado com println e printf
try (InputStream raw = new FileInputStream("archive.gz");
     InputStream gunzip = new GZIPInputStream(raw);
     Reader decoded = new InputStreamReader(gunzip, StandardCharsets.UTF_8);
     BufferedReader lines = new BufferedReader(decoded)) {
    String line;
    while ((line = lines.readLine()) != null) {
        process(line);
    }
}

Lendo da direita para a esquerda: um arquivo, descompactado, decodificado como UTF-8, com buffer e entregue linha a linha. Fechar o stream mais externo fecha a cadeia inteira. Ler um FileInputStream byte a byte sem buffer é uma das maneiras clássicas de fazer o Java parecer lento; um buffer de 8 KB (o padrão) resolve.

O método read(byte[]) retorna o número de bytes efetivamente lidos, que pode ser menor do que o solicitado, e -1 no fim do stream. Código que ignora esse valor de retorno está errado; na prática, use readAllBytes(), readNBytes(n) ou transferTo(out) (Java 9), que cuidam do loop para você.

Path: um endereço imutável no sistema de arquivos

Um Path é um valor que descreve um local, e esse local não precisa existir. Crie um com Path.of("data", "in.csv") (Java 11) ou Paths.get(...) e manipule-o com métodos que retornam novos caminhos:

Path base = Path.of("/var/app");
Path config = base.resolve("conf/app.yml");          // /var/app/conf/app.yml
Path parent = config.getParent();                    // /var/app/conf
Path name = config.getFileName();                    // app.yml
Path rel = base.relativize(config);                  // conf/app.yml
Path clean = Path.of("/var/app/../log/./x").normalize();   // /var/log/x
Path abs = Path.of("x").toAbsolutePath();            // resolved against the working directory
Path real = config.toRealPath();                     // follows symlinks; throws if it does not exist

resolve junta caminhos; relativize é o inverso; normalize remove . e .. de forma léxica (sem tocar no disco); toRealPath consulta o sistema de arquivos. Path implementa Comparable e Iterable<Path> sobre seus elementos de nome. Os separadores ficam por conta do FileSystem ao qual o caminho pertence, então o mesmo código roda no Windows e no Linux: Path.of("a", "b") é a forma portável de escrever.

Nunca monte caminhos concatenando strings a partir de entrada do usuário. base.resolve(userInput).normalize(), seguido de uma verificação de que o resultado ainda satisfaz startsWith(base), é a defesa mínima contra path traversal do tipo ../../etc/passwd.

Files: as operações do dia a dia

Files é uma classe de métodos estáticos que operam sobre Path. Ela lança IOException (normalmente um subtipo específico: NoSuchFileException, AccessDeniedException, FileAlreadyExistsException) em vez de retornar false, e cobre quase tudo o que o antigo File não conseguia fazer.

Path file = Path.of("report.txt");

// Whole-file text (Java 11); always UTF-8 unless you pass another charset
String text = Files.readString(file);
Files.writeString(file, "hello\n", StandardOpenOption.CREATE, StandardOpenOption.APPEND);

// Whole-file lines and bytes
List<String> lines = Files.readAllLines(file);
byte[] bytes = Files.readAllBytes(file);
Files.write(file, List.of("a", "b"));

// Streaming, for large files: closes the file only when the stream is closed
try (Stream<String> stream = Files.lines(file)) {
    long errors = stream.filter(l -> l.contains("ERROR")).count();
}

// Buffered reader/writer, when you want the classic loop
try (BufferedWriter w = Files.newBufferedWriter(file, StandardCharsets.UTF_8)) {
    w.write("first line");
    w.newLine();
}

// Metadata and existence
boolean exists = Files.exists(file);
long size = Files.size(file);
Instant modified = Files.getLastModifiedTime(file).toInstant();
boolean dir = Files.isDirectory(file);

// Structure
Files.createDirectories(Path.of("out/reports"));          // mkdir -p, no error if present
Files.copy(file, Path.of("backup.txt"), StandardCopyOption.REPLACE_EXISTING);
Files.move(file, Path.of("archive/report.txt"), StandardCopyOption.ATOMIC_MOVE);
Files.deleteIfExists(Path.of("tmp.txt"));
Path temp = Files.createTempFile("upload-", ".bin");

StandardOpenOption controla como um arquivo é aberto: CREATE, CREATE_NEW (falha se o arquivo existir), APPEND, TRUNCATE_EXISTING, WRITE. Por padrão, os métodos de gravação criam o arquivo ou o truncam. ATOMIC_MOVE garante que a renomeação seja tudo ou nada dentro do mesmo sistema de arquivos, e é assim que se publica um arquivo com segurança: grave com um nome temporário e depois mova.

Prefira os métodos de Files a construir FileInputStreams diretamente. Eles escolhem tamanhos de buffer sensatos, tratam charsets de forma uniforme, e newInputStream/newOutputStream existem para quando outra API precisa de um stream bruto.

Files.readAttributes(path, BasicFileAttributes.class) retorna tamanho, timestamps e tipo em uma única chamada de sistema, o que faz diferença quando você varre milhares de arquivos. PosixFileAttributes acrescenta dono, grupo e bits de permissão no Unix; Files.setPosixFilePermissions(path, PosixFilePermissions.fromString("rw-r-----")) os altera. Links simbólicos são seguidos por padrão; passe LinkOption.NOFOLLOW_LINKS para inspecionar o próprio link, e use Files.createSymbolicLink e readSymbolicLink para gerenciá-los.

Diretórios: listar, percorrer, monitorar

Files.list(dir) gera um stream com os filhos diretos; Files.walk(dir) percorre a árvore inteira em profundidade; Files.find(dir, depth, matcher) filtra já com os atributos em mãos. Os três retornam um Stream<Path> apoiado em um descritor de diretório aberto, então coloque-os em um try-with-resources.

try (Stream<Path> paths = Files.walk(Path.of("src"))) {
    List<Path> javaFiles = paths
            .filter(Files::isRegularFile)
            .filter(p -> p.toString().endsWith(".java"))
            .toList();
}

// Glob matching, the shell way
PathMatcher logs = FileSystems.getDefault().getPathMatcher("glob:**/*.log");

Files.walkFileTree(start, visitor) é a forma mais antiga e mais controlável: um FileVisitor recebe callbacks antes e depois de cada diretório, para cada arquivo e para cada falha, pode pular subárvores e é a ferramenta certa para exclusão ou cópia recursiva em que os erros precisam ser tratados arquivo a arquivo.

O WatchService emite eventos quando entradas de um diretório registrado são criadas, modificadas ou excluídas. É a base do “recarregar a configuração quando o arquivo mudar” e das integrações baseadas em soltar arquivos numa pasta:

try (WatchService watcher = FileSystems.getDefault().newWatchService()) {
    Path inbox = Path.of("inbox");
    inbox.register(watcher, StandardWatchEventKinds.ENTRY_CREATE);
    while (true) {
        WatchKey key = watcher.take();                          // blocks until an event
        for (WatchEvent<?> event : key.pollEvents()) {
            Path created = inbox.resolve((Path) event.context());
            handleNewFile(created);
        }
        if (!key.reset()) break;                                // directory no longer accessible
    }
}

A especificação traz duas ressalvas: o registro é por diretório, não recursivo (percorra a árvore e registre cada subdiretório), e a implementação do macOS historicamente fazia polling em vez de usar eventos nativos, então lá a latência é de segundos, não de milissegundos. Um evento ENTRY_CREATE também pode disparar antes de quem está gravando terminar; espere por um ENTRY_MODIFY correspondente ou até o tamanho parar de mudar antes de processar o arquivo.

Encodings de caracteres

Texto é bytes mais um charset, e errar o charset produz mojibake ou uma exceção longe da causa. Até o Java 17, toda API chamada sem charset usava o charset padrão da plataforma: UTF-8 no Linux, Windows-1252 em muitas máquinas Windows. Com isso, o mesmo JAR se comportava de forma diferente em servidores diferentes. A JEP 400 tornou o UTF-8 o padrão no Java 18 para new FileReader, new InputStreamReader(in), String.getBytes(), Scanner e as demais APIs sem charset (os métodos de texto de Files sempre usaram UTF-8 por padrão). Restam duas exceções: System.out/System.err e Console usam o encoding do terminal, e ainda é possível passar -Dfile.encoding=COMPAT para restaurar o comportamento antigo.

Seja explícito mesmo assim. Escrever StandardCharsets.UTF_8 não custa nada e deixa a intenção visível para quem lê. Para arquivos que chegam em outros encodings, informe o nome: Files.newBufferedReader(path, Charset.forName("windows-1252")). Quando você decodifica bytes inválidos para o charset, o padrão é substituí-los por U+FFFD; configure um CharsetDecoder com CodingErrorAction.REPORT se preferir que falhe.

Um detalhe relacionado: String.length() conta unidades de código UTF-16, não caracteres, então um emoji tem comprimento 2. Use codePointCount quando a diferença importar, e a ferramenta de Base64 ou o HexFormat (Java 17) quando precisar olhar bytes brutos durante a depuração.

Channels, buffers e arquivos mapeados em memória

O java.nio (Java 1.4) introduziu os channels, conexões bidirecionais com um arquivo ou socket, e os buffers, contêineres de tamanho fixo com posição, limite e capacidade. Um FileChannel faz o que os streams não fazem: ler e gravar em uma posição arbitrária, travar regiões, transferir diretamente para outro channel e mapear o arquivo em memória.

try (FileChannel channel = FileChannel.open(Path.of("data.bin"), StandardOpenOption.READ)) {
    ByteBuffer header = ByteBuffer.allocate(16);
    channel.read(header, 0);                     // positional read, does not move the channel position
    header.flip();                               // switch from writing into the buffer to reading from it
    int magic = header.getInt();
    long recordCount = header.getLong();

    // Map the rest for random access without copying into the heap
    MappedByteBuffer data = channel.map(FileChannel.MapMode.READ_ONLY, 16, channel.size() - 16);
    byte first = data.get(0);
}

// Zero-copy file to socket or file to file
try (FileChannel in = FileChannel.open(src); FileChannel out = FileChannel.open(dst, StandardOpenOption.WRITE, StandardOpenOption.CREATE)) {
    long position = 0, size = in.size();
    while (position < size) position += in.transferTo(position, size - position, out);
}

A dança de flip()/clear()/compact() no ByteBuffer é onde os bugs moram: esquecer o flip() depois de preencher um buffer faz você ler lixo do fim do buffer. Buffers diretos (allocateDirect) ficam fora do heap e evitam uma cópia no I/O, ao custo de uma alocação mais lenta; use-os para buffers de vida longa, não um por requisição. Arquivos mapeados em memória são excelentes para grandes volumes de dados majoritariamente lidos e péssimos para arquivos pequenos (o custo do mapeamento domina), e não existe uma forma portável de desmapear um antes de o buffer ser coletado pelo garbage collector.

Sockets não bloqueantes com Selector também fazem parte do java.nio, mas o código de aplicação raramente mexe neles: Netty, o HttpClient do JDK e todo framework de servidor cuidam dessa camada. Para arquivos, o AsynchronousFileChannel existe, mas acrescenta pouco em relação a um pool de threads; e com as virtual threads, o bom e velho I/O de arquivo bloqueante em uma virtual thread é o jeito mais simples de obter concorrência.

Gerenciamento de recursos e erros

Todo arquivo, channel, stream de diretório e stream de Files.lines aberto segura um descritor do sistema operacional. Os processos têm um limite (muitas vezes 1.024 ou 65.536 no Linux), e um vazamento de um descritor por requisição vira IOException: Too many open files poucas horas depois do deploy. A regra é mecânica: tudo o que implementa AutoCloseable vai em um try-with-resources, como explica o guia de exceções. Ferramentas de análise estática apontam a omissão; ative essa regra.

IOException é checked, e a maioria de quem a recebe não tem como se recuperar. Traduza-a na sua fronteira para UncheckedIOException com o caminho na mensagem, new UncheckedIOException("Cannot read " + path, e), para que o log diga qual arquivo falhou. Files já lança subtipos com o caminho na mensagem; preserve-o ao encapsular a exceção.

Gravações parciais são o outro modo de falha. Um crash no meio da gravação deixa um arquivo truncado que a próxima execução lê sem reclamar. O padrão para gravações duráveis: grave em um arquivo temporário no mesmo diretório, faça force(true) no channel ou um fsync, e depois Files.move com ATOMIC_MOVE. Os leitores veem ou o arquivo antigo ou o novo completo.

Erros comuns

Leituras byte a byte sem buffer. FileInputStream.read() em um loop faz uma chamada de sistema por byte. Envolva em um buffer ou use readAllBytes.

Ignorar o valor de retorno de read(byte[]). Ele pode preencher só parte do array. Faça um loop, ou use readNBytes/transferTo.

Files.lines, list e walk sem try-with-resources. Eles mantêm o diretório ou o arquivo aberto até serem fechados; em um serviço de longa duração, isso é vazamento de descritores.

Charsets padrão. No Java 17 e anteriores, new FileReader(f) usa o que o sistema operacional disser. Passe StandardCharsets.UTF_8 em todo lugar e defina -Dfile.encoding=UTF-8 em JVMs antigas.

File.delete() retornando false em silêncio. Use Files.delete, que lança uma exceção com o motivo.

Caminhos montados com strings. dir + "/" + name quebra no Windows e abre caminho para path traversal. Use resolve e valide em relação ao diretório base.

Ler de uma vez um arquivo que pode ser enorme. readAllBytes em um log de 4 GB é um OutOfMemoryError. Processe como stream.

Serialização Java para persistência ou transporte. Frágil entre versões e um vetor conhecido de execução remota de código. Use JSON, Protobuf ou um banco de dados.

Principais pontos

  • Streams de bytes transportam dados binários, streams de caracteres transportam texto decodificado, e decorators como BufferedReader ou GZIPInputStream se compõem em volta deles; feche o mais externo em um try-with-resources.
  • Use Path e Files para todo trabalho com o sistema de arquivos; java.io.File é legado e falha sem explicar o motivo.
  • Files.readString, writeString, lines, walk, copy, move e createDirectories cobrem a maioria das necessidades; um ATOMIC_MOVE depois de gravar um arquivo temporário garante atualizações à prova de crash.
  • Os streams retornados por Files.lines, list e walk seguram descritores: feche-os sempre.
  • Informe o charset explicitamente, mesmo o UTF-8 sendo o padrão desde o Java 18.
  • Channels e ByteBuffer compensam a complexidade para acesso posicional, lock, mapeamento em memória e transferência zero-copy; para todo o resto, os métodos de Files são mais simples e rápidos o bastante.

Fontes e leituras complementares

Perguntas frequentes

Devo usar java.io.File ou java.nio.file.Path?
Path e Files, para qualquer código novo. Os métodos de File retornam false quando falham, sem dizer o motivo, não lidam direito com links simbólicos nem com atributos de arquivo e não oferecem percurso nem monitoramento de diretórios. Path é um valor imutável que conhece o seu sistema de arquivos, e Files lança IOExceptions descritivas. Converta com file.toPath() quando uma biblioteca ainda te entregar um File.
Qual é a diferença entre um InputStream e um Reader?
Um InputStream entrega bytes brutos; um Reader entrega caracteres decodificados. Texto precisa ser decodificado com um charset, e é exatamente isso que o InputStreamReader faz entre os dois. Use streams para dados binários como imagens, arquivos compactados e payloads de protocolos, e readers para texto, sempre informando o charset.
Files.lines é a mesma coisa que Files.readAllLines?
Não. readAllLines carrega o arquivo inteiro em uma List<String>; lines retorna um Stream<String> lazy, que lê sob demanda e precisa ser fechado com try-with-resources, porque mantém o arquivo aberto. Use lines para arquivos grandes ou quando for parar antes do fim, e readAllLines para arquivos pequenos que você vai precisar em memória de qualquer jeito.
Quando preciso de channels e ByteBuffer em vez de streams?
Raramente. Channels oferecem acesso aleatório, lock de arquivos, mapeamento em memória, scatter/gather e transferTo, e são a base do I/O de rede não bloqueante. Para a leitura e a gravação comuns de arquivos, os métodos utilitários de Files e os streams com buffer são mais simples e igualmente rápidos. Recorra ao FileChannel quando precisar de leituras posicionais, de mmap de um arquivo grande ou de transferência zero-copy entre channels.