Como ler e gravar arquivos com a API moderna do Java, e como garantir que todo recurso aberto seja fechado, mesmo quando algo dá errado.
"Quando o Contrato se Quebra" terminou com um problema: o finally garante que um código de limpeza execute, mas fica verboso e tem armadilhas quando o próprio fechamento falha. Os recursos mais comuns de um programa são arquivos, e esta aula trata dos dois assuntos juntos. Você vai conhecer a API de arquivos do Java, chamada NIO.2, as primeiras exceções verificadas reais do curso, e o try-with-resources, a forma segura e curta de trabalhar com qualquer coisa que precise ser fechada. No fim, um programa lê um arquivo de notas, trata linhas inválidas e grava um relatório.
Caminhos: a classe Path
Um arquivo é identificado por um caminho. No Java moderno, caminhos são objetos da interface Path, criados com Path.of:
void main() {
Path pasta = Path.of("dados", "2026");
Path arquivo = pasta.resolve("notas.csv");
IO.println(arquivo); // dados/2026/notas.csv
IO.println(arquivo.getFileName() + " | " + arquivo.getParent()); // notas.csv | dados/2026
IO.println(Path.of("dados/./2026/../2025/notas.csv").normalize()); // dados/2025/notas.csv
IO.println(Files.exists(arquivo)); // false
}
Passar as partes separadas para Path.of, em vez de montar um texto com barras, deixa o Java usar o separador certo em cada sistema: a barra / no Linux e no macOS, e a barra invertida \ no Windows. resolve junta um caminho a outro. normalize remove os . e os .. redundantes. Um Path é só um nome: criar um não verifica se o arquivo existe, e manipulá-lo não toca no disco. Caminhos como dados/2026 são relativos à pasta em que o programa foi executado, e toAbsolutePath() os converte no caminho completo.
Os exemplos desta aula usam arquivos compactos. Neles, as classes do pacote java.nio.file estão disponíveis sem importação, como tudo o que é da biblioteca básica. Numa classe clássica, o topo do arquivo teria import java.nio.file.*; e import java.io.*;.
Files: as operações do dia a dia
A classe utilitária Files concentra as operações sobre arquivos. Para arquivos pequenos, ler e gravar é uma linha cada:
void main() throws IOException {
Path pasta = Path.of("saida");
Files.createDirectories(pasta);
Path arquivo = pasta.resolve("diario.txt");
Files.writeString(arquivo, "Dia 1: comecei o curso de Java.\n");
Files.writeString(arquivo, "Dia 2: tipos e variáveis.\n", StandardOpenOption.APPEND);
Files.write(arquivo, List.of("Dia 3: operadores.", "Dia 4: controle de fluxo."),
StandardOpenOption.APPEND);
String tudo = Files.readString(arquivo);
IO.print(tudo);
List<String> linhas = Files.readAllLines(arquivo);
IO.println(linhas.size() + " linhas; a última: " + linhas.getLast());
IO.println(Files.size(arquivo) + " bytes");
}
Dia 1: comecei o curso de Java.
Dia 2: tipos e variáveis.
Dia 3: operadores.
Dia 4: controle de fluxo.
4 linhas; a última: Dia 4: controle de fluxo.
104 bytes
createDirectoriescria a pasta e todas as que faltarem no caminho, e não reclama se ela já existe.writeStringgrava um texto, substituindo o conteúdo anterior. ComStandardOpenOption.APPEND, acrescenta no fim.writegrava uma lista de linhas.readStringlê o arquivo inteiro como texto, ereadAllLineso lê como uma lista, uma linha por elemento.
Todos esses métodos usam UTF-8 quando nenhuma codificação é indicada. Os de Files sempre fizeram assim; desde o Java 18 o UTF-8 também virou o padrão do resto da biblioteca, como mencionado em "Do Código ao Bytecode". Isso explica os 104 bytes: o arquivo tem 103 caracteres, mas o "á" de "variáveis" ocupa dois bytes em UTF-8. Caracteres e bytes são coisas diferentes, e a diferença importa sempre que um arquivo sai do seu programa.
Outras operações frequentes:
Files.copy(origem, destino, StandardCopyOption.REPLACE_EXISTING);
Files.move(origem, destino, StandardCopyOption.REPLACE_EXISTING);
Files.delete(arquivo); // lança exceção se não existir
Files.deleteIfExists(arquivo); // devolve false se não existir
Files.exists(arquivo);
Files.isDirectory(caminho);
IOException: a primeira exceção verificada de verdade
Repare no throws IOException do main. Quase todos os métodos de Files declaram IOException, uma exceção verificada, exatamente o caso que "Quando o Contrato se Quebra" apontou como o uso certo delas: um arquivo pode não existir, a permissão pode ser negada, o disco pode encher. Nada disso é um erro de programação, e um programa correto precisa estar preparado. Sem tratar nem declarar, o código não compila:
SemThrows.java:2: error: unreported exception IOException; must be caught or declared to be thrown
Files.readString(Path.of("x.txt"));
^
Declarar throws IOException no main é aceitável em exemplos, porque qualquer falha simplesmente encerra o programa com o stack trace. Num programa de verdade, o tratamento deve dizer ao usuário o que aconteceu. A IOException tem subclasses específicas, que permitem reações diferentes:
try {
Files.readString(Path.of("nao-existe.txt"));
} catch (NoSuchFileException e) {
IO.println("Arquivo não encontrado: " + e.getFile());
} catch (IOException e) {
IO.println("Erro de leitura: " + e.getMessage());
}
Arquivo não encontrado: nao-existe.txt
A mais específica vem primeiro, pela regra da ordem dos catch. AccessDeniedException e FileAlreadyExistsException são outras subclasses comuns.
Arquivos grandes: ler aos poucos
readString e readAllLines carregam o arquivo inteiro na memória. Para um arquivo de configuração, tudo bem. Para um log de 10 GB, o programa estoura a memória. Arquivos grandes devem ser lidos linha a linha, com um leitor:
BufferedReader leitor = Files.newBufferedReader(arquivo);
String linha;
while ((linha = leitor.readLine()) != null) {
// processa uma linha por vez
}
leitor.close();
readLine devolve a próxima linha, ou null quando o arquivo acaba. A condição do while atribui e testa na mesma expressão, um padrão clássico de leitura em Java. O "Buffered" do nome significa que o leitor lê blocos grandes do disco de uma vez e os entrega aos poucos, o que é muito mais rápido do que ir ao disco a cada caractere. Há o equivalente para gravação, Files.newBufferedWriter.
Mas há um problema sério nesse código: se o processamento de alguma linha lançar uma exceção, o leitor.close() nunca executa, e o arquivo fica aberto. Cada arquivo aberto consome um recurso do sistema operacional, e um programa que vaza esses recursos acaba recusado pelo sistema com "Too many open files". É exatamente o problema de "Quando o Contrato se Quebra", e a solução clássica seria o finally.
O jeito antigo: finally com cuidados
Fechar corretamente com finally exige isto:
BufferedReader leitor = null;
try {
leitor = Files.newBufferedReader(Path.of("dados", "notas.csv"));
IO.println(leitor.readLine());
} catch (IOException e) {
IO.println("erro: " + e.getMessage());
} finally {
if (leitor != null) {
try {
leitor.close();
} catch (IOException e) {
IO.println("erro ao fechar: " + e.getMessage());
}
}
}
A variável precisa ser declarada fora do try para ser vista no finally. É preciso testar null, porque a abertura pode ter falhado. E o close também lança IOException, o que exige um try dentro do finally. São quinze linhas para ler uma linha, e com dois recursos, como um arquivo lido e outro gravado, a estrutura dobra. Até o Java 6 era assim, e muitos programas erravam algum detalhe.
try-with-resources
Desde o Java 7, um try pode declarar recursos entre parênteses. Eles são fechados automaticamente no fim do bloco, aconteça o que acontecer:
try (BufferedReader leitor = Files.newBufferedReader(Path.of("dados", "notas.csv"))) {
IO.println(leitor.readLine());
} catch (IOException e) {
IO.println("erro: " + e.getMessage());
}
É o mesmo comportamento das quinze linhas anteriores, sem nenhuma delas. Funciona com qualquer objeto que implemente a interface AutoCloseable, de um só método, close(), uma das interfaces "sei fazer" de "Contratos sem Implementação". Leitores, gravadores, conexões de banco de dados e de rede implementam essa interface. As suas classes também podem implementá-la:
class Recurso implements AutoCloseable {
private final String nome;
Recurso(String nome) {
this.nome = nome;
IO.println("abrindo " + nome);
}
void usar(boolean falhar) {
IO.println("usando " + nome);
if (falhar) {
throw new IllegalStateException("falha ao usar " + nome);
}
}
@Override
public void close() {
IO.println("fechando " + nome);
}
}
void main() {
try (Recurso a = new Recurso("A"); Recurso b = new Recurso("B")) {
a.usar(false);
b.usar(true);
} catch (IllegalStateException e) {
IO.println("tratando: " + e.getMessage());
}
}
abrindo A
abrindo B
usando A
usando B
fechando B
fechando A
tratando: falha ao usar B
Três detalhes aparecem na saída. Vários recursos são separados por ponto e vírgula. Eles são fechados na ordem inversa da abertura, B antes de A, porque um recurso aberto depois pode depender de um aberto antes, como um gravador que usa uma conexão. E os dois são fechados antes de o catch executar: quando o tratamento começa, tudo já foi liberado.
Exceções suprimidas
E se o próprio close lançar uma exceção, enquanto outra já está em andamento? Com o finally manual, a exceção do fechamento substituía a original, que se perdia, e com ela a causa real do problema. O try-with-resources resolve isso:
class Teimoso implements AutoCloseable {
void usar() { throw new IllegalStateException("erro no uso"); }
@Override
public void close() { throw new IllegalStateException("erro no fechamento"); }
}
try (Teimoso t = new Teimoso()) {
t.usar();
} catch (IllegalStateException e) {
IO.println("principal: " + e.getMessage());
for (Throwable s : e.getSuppressed()) {
IO.println("suprimida: " + s.getMessage());
}
}
principal: erro no uso
suprimida: erro no fechamento
A exceção original, a que causou o problema, é a que chega ao catch. A do fechamento vai junto, anexada como suprimida, e aparece no stack trace numa seção "Suppressed". Com o finally manual, o mesmo código fazia chegar só "erro no fechamento", e a causa real sumia. Use try-with-resources para tudo o que precisa ser fechado. Não há motivo para escrever o finally manual em código novo.
Listando pastas
Files.newDirectoryStream percorre o conteúdo de uma pasta, opcionalmente filtrado por um padrão. Ele mantém um recurso do sistema aberto enquanto percorre, e por isso também vai num try-with-resources:
try (DirectoryStream<Path> conteudo = Files.newDirectoryStream(Path.of("dados"), "*.{csv,txt}")) {
for (Path p : conteudo) {
IO.println(p.getFileName() + " (" + Files.size(p) + " bytes)");
}
}
O padrão *.{csv,txt} é um glob, a mesma sintaxe de curingas dos terminais. Files também tem os métodos list, walk e lines, que devolvem streams, a ferramenta de "Pare de Escrever Laços".
Texto e bytes
Tudo até aqui tratou arquivos como texto. Por baixo, todo arquivo é uma sequência de bytes, e a conversão entre bytes e caracteres depende de uma codificação, como o UTF-8. Por isso o Java tem duas famílias de classes de entrada e saída. InputStream e OutputStream trabalham com bytes crus, para imagens, áudio e arquivos compactados. Reader e Writer trabalham com caracteres, para texto. Files.readAllBytes lê qualquer arquivo como um byte[]:
byte[] bytes = Files.readAllBytes(Path.of("dados", "notas.csv"));
IO.println(bytes.length + " bytes; primeiro: " + bytes[0] + " = '" + (char) bytes[0] + "'");
// 162 bytes; primeiro: 97 = 'a'
IO.println("Matemática".getBytes(StandardCharsets.UTF_8).length + " " + "Matemática".length());
// 11 10
"Matemática" tem 10 caracteres e 11 bytes. Um programa que confunde as duas medidas, ao cortar um texto num limite de bytes, por exemplo, pode partir um caractere ao meio e corromper o texto.
Juntando tudo: um relatório de notas
Um arquivo dados/notas.csv, no formato CSV (valores separados por um delimitador, aqui ponto e vírgula), com uma linha propositalmente inválida:
aluno;disciplina;nota
Ana;Matemática;9.0
Bruno;Matemática;6.5
Ana;História;8.0
Carla;Matemática;7.5
Bruno;História;abc
Carla;História;9.5
Bruno;Física;7.0
O programa lê o arquivo linha a linha, ignora com aviso as linhas inválidas, agrupa as notas por aluno e grava um relatório:
record Nota(String aluno, String disciplina, double valor) { }
void main() {
Path entrada = Path.of("dados", "notas.csv");
Path saida = Path.of("dados", "relatorio.txt");
List<Nota> notas = new ArrayList<>();
List<String> problemas = new ArrayList<>();
try (BufferedReader leitor = Files.newBufferedReader(entrada)) {
leitor.readLine(); // descarta o cabeçalho
String linha;
int numero = 1;
while ((linha = leitor.readLine()) != null) {
numero++;
String[] campos = linha.split(";");
try {
notas.add(new Nota(campos[0], campos[1], Double.parseDouble(campos[2])));
} catch (NumberFormatException | ArrayIndexOutOfBoundsException e) {
problemas.add("linha " + numero + " ignorada: " + linha);
}
}
} catch (IOException e) {
IO.println("Não foi possível ler " + entrada + ": " + e.getMessage());
return;
}
Map<String, List<Double>> porAluno = new TreeMap<>();
for (Nota n : notas) {
List<Double> lista = porAluno.get(n.aluno());
if (lista == null) {
lista = new ArrayList<>();
porAluno.put(n.aluno(), lista);
}
lista.add(n.valor());
}
try (BufferedWriter escritor = Files.newBufferedWriter(saida)) {
escritor.write("Médias por aluno");
escritor.newLine();
for (Map.Entry<String, List<Double>> e : porAluno.entrySet()) {
double soma = 0;
for (double v : e.getValue()) {
soma += v;
}
escritor.write("%-6s %5.2f (%d notas)".formatted(
e.getKey(), soma / e.getValue().size(), e.getValue().size()));
escritor.newLine();
}
for (String p : problemas) {
escritor.write("AVISO: " + p);
escritor.newLine();
}
} catch (IOException e) {
IO.println("Não foi possível gravar " + saida + ": " + e.getMessage());
return;
}
IO.println("Relatório gravado em " + saida);
}
O arquivo relatorio.txt gerado:
Médias por aluno
Ana 8.50 (2 notas)
Bruno 6.75 (2 notas)
Carla 8.50 (2 notas)
AVISO: linha 6 ignorada: Bruno;História;abc
O programa junta boa parte das últimas aulas. Há dois níveis de tratamento de erro: uma linha inválida é um problema local, registrado e ignorado, enquanto uma falha de leitura do arquivo inteiro é um problema global, que encerra o processamento com uma mensagem. O catch interno lista apenas as exceções esperadas de uma linha malformada, pela lição de "Quando o Contrato se Quebra". O record Nota dá nome aos campos da linha. O TreeMap de listas é o padrão da agenda de "Listas, Conjuntos e Mapas". E os dois arquivos são fechados automaticamente, com ou sem erro.
Nem tudo no programa está certo. A média é calculada com double, com a imprecisão já conhecida, o que seria inaceitável se as notas fossem valores em dinheiro. E se o arquivo tivesse datas de entrega, lê-las e compará-las com String seria frágil. Datas, horas, fusos horários e valores monetários têm cada um a sua classe própria na biblioteca, e validar formatos de texto, como o de uma data ou de um CEP, tem uma ferramenta própria, as expressões regulares.
Fontes e leituras recomendadas
- Oracle, The Java Tutorials, "File I/O (Featuring NIO.2)": https://docs.oracle.com/javase/tutorial/essential/io/fileio.html. A trilha completa da API de arquivos, com
Path,Files, permissões e varredura de pastas. - Oracle, The Java Tutorials, "The try-with-resources Statement": https://docs.oracle.com/javase/tutorial/essential/exceptions/tryResourceClose.html. As regras do try-with-resources, incluindo as exceções suprimidas.
- Documentação da classe
Files(Java SE 25): https://docs.oracle.com/en/java/javase/25/docs/api/java.base/java/nio/file/Files.html. Todos os métodos utilitários, com as exceções que cada um pode lançar. - JEP 400, "UTF-8 by Default": https://openjdk.org/jeps/400. A mudança do Java 18 que tornou o UTF-8 a codificação padrão, com a explicação dos problemas que existiam antes.
- Joshua Bloch, Effective Java, 3ª edição, item 9: "Prefer try-with-resources to try-finally", com o exemplo da exceção perdida no
finallymanual.
Exercícios
Exercício 1
No exemplo com os recursos A e B, suponha que a abertura de B lance uma exceção, depois que A já foi aberto. Preveja o que é impresso, e explique como o try-with-resources lida com isso.
Ver resposta
✓ Resposta: São impressos "abrindo A", depois a exceção acontece no construtor de B, então "fechando A", e por fim o tratamento no catch. B nunca foi criado, então não há o que fechar, e ele não aparece. O try-with-resources só fecha os recursos que foram inicializados com sucesso, e sempre na ordem inversa. A, que já estava aberto, é fechado antes de a exceção chegar ao catch. Com o finally manual, garantir esse comportamento exigiria testes de null para cada recurso, e é justamente nesse tipo de caso que os programas erravam.
Exercício 2
Explique a diferença entre o que chega ao catch quando o close lança uma exceção durante o tratamento de outra, com try-with-resources e com o finally manual. Por que a diferença importa para quem investiga um problema?
Ver resposta
✓ Resposta: Com try-with-resources, chega ao catch a exceção original, a do uso do recurso, e a exceção do close vem anexada como suprimida, acessível por getSuppressed() e visível no stack trace. Com o finally manual, a exceção lançada no finally substitui a que estava em andamento, e a original é descartada. Para quem investiga, a original é quase sempre a causa real: o recurso falhou ao ser usado, e a falha no fechamento é uma consequência, porque o recurso já estava num estado ruim. Com o finally manual, o log mostraria apenas "erro ao fechar", e a pessoa passaria horas investigando o fechamento, que não é o problema.
Exercício 3
Escreva um programa que receba o nome de um arquivo pela linha de comando e mostre quantas linhas, palavras e caracteres ele tem, como o comando wc do Unix. Se o arquivo não existir, mostre uma mensagem clara.
Ver resposta
✓ Resposta:
void main(String[] args) {
Path arquivo = Path.of(args[0]);
try {
List<String> linhas = Files.readAllLines(arquivo);
int palavras = 0;
int caracteres = 0;
for (String linha : linhas) {
caracteres += linha.length();
if (!linha.isBlank()) {
palavras += linha.strip().split("\\s+").length;
}
}
IO.println(linhas.size() + " linhas, " + palavras + " palavras, " + caracteres + " caracteres");
} catch (NoSuchFileException e) {
IO.println("Arquivo não encontrado: " + arquivo);
} catch (IOException e) {
IO.println("Erro ao ler " + arquivo + ": " + e.getMessage());
}
}
Com um arquivo de quatro linhas, uma delas vazia, a saída é algo como "4 linhas, 12 palavras, 73 caracteres". O split("\\s+") divide por um ou mais espaços, uma expressão regular da próxima aula. Linhas em branco são tratadas à parte, porque "".split(...) devolveria um elemento vazio e contaria uma palavra que não existe. A contagem de caracteres não inclui as quebras de linha, que readAllLines remove, e por isso difere do wc real, que as conta. Para arquivos muito grandes, a versão certa leria com BufferedReader, linha a linha.
Exercício 4
Um programa usa Files.readString para processar os logs de um servidor. Funcionou por meses e, de repente, passou a falhar com OutOfMemoryError. Explique o que provavelmente aconteceu e como corrigir.
Ver resposta
✓ Resposta: readString carrega o arquivo inteiro na memória, como um único String. Enquanto os logs eram pequenos, isso cabia na memória da JVM. Com o crescimento do tráfego ou a falta de rotação dos logs, o arquivo passou do tamanho que a memória disponível comporta, e a JVM não conseguiu alocar o texto. Em Java, cada caractere ocupa até dois bytes na memória, então um log de 2 GB pode precisar de 4 GB. A correção é processar o arquivo aos poucos, com Files.newBufferedReader num try-with-resources, lendo e tratando uma linha por vez. Assim, a memória usada fica proporcional ao tamanho de uma linha, e não do arquivo. A lição geral: métodos que leem "tudo de uma vez" servem para arquivos cujo tamanho você controla, e nunca para arquivos que crescem sem limite.
Exercício 5
Escreva uma classe Cronometro que implemente AutoCloseable, anote o instante da criação e, ao ser fechada, imprima quanto tempo passou. Use-a para medir quanto tempo leva ordenar um milhão de inteiros.
Ver resposta
✓ Resposta:
class Cronometro implements AutoCloseable {
private final String tarefa;
private final long inicio = System.nanoTime();
Cronometro(String tarefa) {
this.tarefa = tarefa;
}
@Override
public void close() {
long ms = (System.nanoTime() - inicio) / 1_000_000;
IO.println(tarefa + " levou " + ms + " ms");
}
}
void main() {
try (Cronometro c = new Cronometro("ordenação")) {
List<Integer> nums = new ArrayList<>();
for (int i = 0; i < 1_000_000; i++) {
nums.add((i * 7919) % 1_000_003);
}
Collections.sort(nums);
}
}
A saída é algo como "ordenação levou 320 ms", e o valor varia com a máquina. O close desta classe não lança exceção verificada, porque a sobrescrita declara public void close() sem throws, e por isso o try não precisa de catch. O exercício mostra que o try-with-resources não serve só para arquivos: serve para qualquer ação que deva acontecer ao sair de um bloco, como medir tempo, liberar uma trava ou registrar o fim de uma operação. Medições de desempenho sérias exigem mais cuidado, porque o JIT e o coletor de lixo interferem nos números, e o assunto volta em "Medir Antes de Otimizar".