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:
| Decorator | Acrescenta |
|---|---|
BufferedInputStream / BufferedReader | Um 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 / DataOutputStream | Tipos primitivos em um formato binário portável |
ObjectInputStream / ObjectOutputStream | Serialização Java (evite para qualquer coisa que atravesse uma fronteira de confiança) |
GZIPInputStream / InflaterInputStream | Descompactação |
PrintWriter / PrintStream | Saí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.
Atributos e links
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
BufferedReaderouGZIPInputStreamse compõem em volta deles; feche o mais externo em um try-with-resources. - Use
PatheFilespara todo trabalho com o sistema de arquivos;java.io.Fileé legado e falha sem explicar o motivo. Files.readString,writeString,lines,walk,copy,moveecreateDirectoriescobrem a maioria das necessidades; umATOMIC_MOVEdepois de gravar um arquivo temporário garante atualizações à prova de crash.- Os streams retornados por
Files.lines,listewalkseguram descritores: feche-os sempre. - Informe o charset explicitamente, mesmo o UTF-8 sendo o padrão desde o Java 18.
- Channels e
ByteBuffercompensam a complexidade para acesso posicional, lock, mapeamento em memória e transferência zero-copy; para todo o resto, os métodos deFilessã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.