O Java divide as exceções em checadas, que o compilador obriga você a tratar, e não checadas, que ele deixa passar. Aprenda try, catch e finally, como lançar e encapsular exceções direito e como o try-with-resources fecha tudo na ordem inversa.
Uma exceção é o jeito de um método Java dizer “não consigo terminar isto”. Algumas exceções o compilador obriga você a tratar, outras não, e essa divisão molda o jeito como o código Java lida com falhas. A outra metade da história é a limpeza: fechar o que você abriu, mesmo quando algo dá errado no meio do caminho.
Este post cobre a hierarquia de exceções, exceções checadas e não checadas, try, catch e finally, como lançar e encapsular exceções, exceções customizadas e try-with-resources. Ele termina com três hábitos para evitar. Todo programa abaixo rodou no Java 25, e a saída foi colada da execução. Para rodar um você mesmo, salve como Main.java e rode java Main.java.
A hierarquia de exceções
Toda exceção em Java é um objeto cuja classe estende Throwable. Subir pelas classes pai de três exceções comuns mostra a árvore genealógica inteira:
void main() {
var types = List.of(
IOException.class,
IllegalArgumentException.class,
StackOverflowError.class);
for (var type : types) {
var chain = new ArrayList<String>();
for (Class<?> c = type; c != null; c = c.getSuperclass()) {
chain.add(c.getSimpleName());
}
IO.println(String.join(" -> ", chain));
}
}
Ele imprime:
IOException -> Exception -> Throwable -> Object
IllegalArgumentException -> RuntimeException -> Exception -> Throwable -> Object
StackOverflowError -> VirtualMachineError -> Error -> Throwable -> Object
Abaixo de Throwable há dois ramos:
Errorsignifica que a própria JVM está com problemas: falta de memória, um stack overflow, um arquivo de classe corrompido. Em geral o seu código não tem como consertar isso, então você não captura esses erros.Exceptionsignifica que algo deu errado e que um programa talvez consiga tratar.
Exception se divide mais uma vez. RuntimeException e as subclasses dela são não checadas. Toda outra Exception é checada. Error também é não checado. A diferença está inteira no que o compilador obriga você a fazer.
Exceções checadas: capture ou declare
Uma exceção checada precisa ser capturada ou declarada na cláusula throws do método, e o compilador recusa código que não faz nenhuma das duas coisas. new URI(text) pode lançar a exceção checada URISyntaxException, então isto não compila:
URI parse(String text) {
return new URI(text);
}
void main() {
IO.println(parse("https://example.com/docs"));
}
O build falha com:
Main.java:2: error: unreported exception URISyntaxException; must be caught or declared to be thrown
return new URI(text);
^
A mensagem cita as duas saídas. Você pode capturar a exceção e lidar com ela onde ela acontece, ou pode adicionar throws URISyntaxException e passar o problema para quem chamou:
String hostOf(String text) {
try {
return new URI(text).getHost();
} catch (URISyntaxException e) {
IO.println("bad address: " + e.getMessage());
return "unknown";
}
}
URI parse(String text) throws URISyntaxException {
return new URI(text);
}
void main() throws URISyntaxException {
IO.println(hostOf("https://example.com/docs"));
IO.println(hostOf("https://example.com/my docs"));
IO.println(parse("https://example.org/api").getPath());
}
Ele imprime:
example.com
bad address: Illegal character in path at index 22: https://example.com/my docs
unknown
/api
hostOf captura a exceção e devolve um valor padrão, então quem chama nunca a vê. parse a declara, então cada chamador precisa fazer a mesma escolha de novo. main também a declara. Se uma exceção escapasse de main, o programa pararia com uma stack trace, como a primeira parte desta série mostrou.
Uma exceção não checada não precisa de nenhuma das duas. Integer.parseInt lança NumberFormatException, uma RuntimeException, e você pode chamá-lo sem try e sem throws.
Explicado como se você tivesse dez anos
Uma exceção checada é um adesivo de “frágil” num pacote. Todo mundo que pega o pacote tem que notar o adesivo. Ou você abre o pacote com cuidado, ou passa adiante com o adesivo ainda à vista. Não vale arrancar o adesivo e fingir que ele não existe.
Uma exceção não checada é um pacote sem adesivo. Ele ainda pode quebrar, mas ninguém é obrigado a pensar nisso no caminho.
A versão precisa
O compilador acompanha quais exceções checadas cada instrução pode lançar. Ele tira isso das cláusulas throws dos métodos e construtores que chama, e das suas próprias instruções throw. Cada uma precisa ser tratada por um catch em volta, para aquele tipo ou um tipo pai, ou listada na cláusula throws do método que você está escrevendo. A verificação acontece em tempo de compilação, e nada dela existe no programa rodando: a JVM trata exceções checadas e não checadas do mesmo jeito.
Onde a analogia falha: um adesivo de verdade viaja com o pacote, e qualquer um pode olhar para ele. A verificação do Java só acontece quando o javac compila código-fonte Java. Em tempo de execução ninguém procura o adesivo, então código compilado de outra linguagem da JVM, ou um truque que esconde o tipo, pode lançar uma exceção checada através de um método que nunca a declarou.
Qual tipo usar
A regra de sempre é: não checada para erros de programação, checada para condições das quais quem chama pode se recuperar de forma razoável. Um null onde ele não é permitido, um índice além do fim ou uma quantidade negativa é um bug no código que chama. Obrigar todo chamador a capturar isso só adicionaria ruído, então essas são RuntimeExceptions. Um arquivo que não existe, uma rede que cai ou um texto digitado pelo usuário que não é um endereço válido é algo que quem chama consegue tratar, pedindo de novo ou usando um valor padrão. O Java fez essas serem checadas.
Essa regra ainda gera discussão. Exceções checadas deixam a falha visível na assinatura do método, e o compilador não deixa você esquecer nenhuma. Mas elas se espalham por todas as camadas do programa, tentam as pessoas a escrever blocos catch vazios só para o erro sumir, e não combinam com lambdas. forEach(s -> IO.println(new URI(s))) falha com o mesmo erro unreported exception, porque forEach recebe uma interface cujo método não declara exceções checadas. Kotlin, Scala e C# escolheram não ter exceções checadas, e muitas bibliotecas Java modernas lançam só exceções não checadas. O próprio JDK continua usando as duas, então você precisa estar à vontade com as duas.
try, catch e finally
Um bloco try roda código que pode lançar uma exceção, e cada catch nomeia um tipo que trata. Um catch pode listar vários tipos separados por |, o que se chama multi-catch:
int port(String text) {
try {
int n = Integer.parseInt(text);
return List.of(80, 443, 8080).get(n);
} catch (NumberFormatException | IndexOutOfBoundsException e) {
IO.println("fallback, because " + e.getClass().getSimpleName());
return 80;
}
}
void main() {
IO.println(port("1"));
IO.println(port("one"));
IO.println(port("7"));
}
Ele imprime:
443
fallback, because NumberFormatException
80
fallback, because ArrayIndexOutOfBoundsException
80
Olhe as últimas linhas. Pedimos o elemento 7 de um List.of com três elementos e esperávamos IndexOutOfBoundsException. A lista na verdade lançou ArrayIndexOutOfBoundsException, uma subclasse, porque ela usa um array por baixo. O catch funcionou mesmo assim, porque um catch trata o tipo nomeado e todas as subclasses dele. É um bom motivo para capturar o tipo documentado em vez de adivinhar a classe exata.
Os tipos de um multi-catch não podem ter relação entre si. NumberFormatException | IllegalArgumentException falha com Alternatives in a multi-catch statement cannot be related by subclassing, porque o pai já cobre o filho.
Um catch amplo antes de um estreito não compila
Os blocos catch são testados de cima para baixo, e o primeiro cujo tipo encaixa ganha. Então um tipo pai acima da própria subclasse deixa o catch de baixo inalcançável:
void main() {
try {
IO.println(Integer.parseInt("forty"));
} catch (RuntimeException e) {
IO.println("something went wrong");
} catch (NumberFormatException e) {
IO.println("not a number");
}
}
O build falha com:
Main.java:6: error: exception NumberFormatException has already been caught
} catch (NumberFormatException e) {
^
Coloque o tipo estreito primeiro e depois o amplo. O compilador também confere a outra direção: capturar uma exceção checada que o corpo do try não pode lançar, como IOException em volta de um simples IO.println, falha com exception IOException is never thrown in body of corresponding try statement.
finally sempre roda
Um bloco finally roda quando o try termina, seja normalmente, lançando uma exceção ou com um return:
String check(String text) {
try {
IO.println("try: parsing " + text);
Integer.parseInt(text);
return "number";
} catch (NumberFormatException e) {
IO.println("catch: " + e.getMessage());
return "not a number";
} finally {
IO.println("finally: done with " + text);
}
}
void main() {
IO.println("result: " + check("42"));
IO.println("result: " + check("forty"));
}
Ele imprime:
try: parsing 42
finally: done with 42
result: number
try: parsing forty
catch: For input string: "forty"
finally: done with forty
result: not a number
O return "number" rodou primeiro e escolheu o valor, mas o método só saiu depois que o finally imprimiu a linha dele. Só então main recebeu o resultado. O mesmo aconteceu no caminho pelo catch.
return dentro de finally engole a exceção
Um bloco finally que faz return substitui o que o try estava fazendo, e isso inclui uma exceção a caminho da saída. O javac avisa sobre isso, mas só quando você pede avisos com -Xlint. Esta série compila todo exemplo com -Xlint:all -Werror, o que transforma o aviso num build que falha:
int risky() {
try {
throw new IllegalStateException("the order was lost");
} finally {
return -1;
}
}
void main() {
IO.println("risky() returned " + risky());
IO.println("no exception reached main");
}
O build falha com:
Main.java:6: warning: [finally] finally clause cannot complete normally
error: warnings found and -Werror specified
Com um simples javac Main.java, ou com java Main.java, não há aviso nenhum, e o programa roda. Para mostrar o que ele faz, esta versão silencia o aviso com @SuppressWarnings("finally"):
@SuppressWarnings("finally")
int risky() {
try {
throw new IllegalStateException("the order was lost");
} finally {
return -1;
}
}
void main() {
IO.println("risky() returned " + risky());
IO.println("no exception reached main");
}
Ele imprime:
risky() returned -1
no exception reached main
A IllegalStateException sumiu. Sem stack trace, sem mensagem, e quem chamou recebeu -1 como se tudo estivesse bem. Use finally só para limpeza, e nunca faça return, break ou throw dentro dele.
Lançando exceções
Uma instrução throw aceita qualquer objeto Throwable, e a coisa mais útil que você pode colocar nele é uma mensagem que diga a quem lê o que estava errado. Para argumentos ruins, o JDK dá duas ferramentas: IllegalArgumentException, e Objects.requireNonNull para null:
record Transfer(String from, String to, long cents) {
Transfer {
Objects.requireNonNull(from, "from account is required");
Objects.requireNonNull(to, "to account is required");
if (cents <= 0) {
throw new IllegalArgumentException(
"cents must be positive, got " + cents + " for " + from + " -> " + to);
}
}
}
void main() {
IO.println(new Transfer("ana", "ben", 500));
try {
new Transfer("ana", null, 500);
} catch (NullPointerException e) {
IO.println(e.getMessage());
}
new Transfer("ana", "ben", -500);
}
Ele imprime e para:
Transfer[from=ana, to=ben, cents=500]
to account is required
Exception in thread "main" java.lang.IllegalArgumentException: cents must be positive, got -500 for ana -> ben
requireNonNull devolve o argumento quando ele não é null, e lança NullPointerException com a sua mensagem quando é. Sem mensagem, getMessage() devolve null, o que não ajuda ninguém.
A mensagem da IllegalArgumentException diz qual é a regra, qual valor a quebrou e a qual transferência ele pertencia. Compare com um simples "invalid". Quando essa linha aparece num log, a primeira versão diz onde procurar. A parte sobre records cobre construtores compactos como este.
Encapsular uma exceção mantém a causa
Quando você captura uma exceção de baixo nível e lança uma mais significativa, passe a original como causa. Toda exceção padrão tem um construtor que recebe (String message, Throwable cause):
int readPort(String text) {
try {
return Integer.parseInt(text);
} catch (NumberFormatException e) {
throw new IllegalStateException("config: port must be a number", e);
}
}
int readPortLogged(String text) {
try {
return readPort(text);
} catch (IllegalStateException e) {
IO.println("log: " + e.getMessage());
throw e;
}
}
void main() {
try {
readPortLogged("eighty");
} catch (IllegalStateException e) {
IO.println("error: " + e.getMessage());
IO.println("caused by: " + e.getCause());
}
}
Ele imprime:
log: config: port must be a number
error: config: port must be a number
caused by: java.lang.NumberFormatException: For input string: "eighty"
Duas coisas acontecem aqui. readPort encapsula: a exceção nova explica o problema em termos de configuração, e getCause() ainda guarda a NumberFormatException original. readPortLogged relança: ele registra no log e depois lança o mesmo objeto com throw e;, então quem chama vê exatamente o que readPort lançou.
Se nada captura uma exceção encapsulada, a stack trace imprime as duas. Depois dos frames da IllegalStateException, vem uma linha começando com Caused by: java.lang.NumberFormatException: For input string: "eighty", seguida dos frames dessa exceção e de uma linha como ... 1 more para os frames que as duas compartilham. Leia uma stack trace longa a partir do último Caused by:, subindo. O de baixo costuma ser onde o problema começou.
Tire o e do construtor e essa segunda metade inteira desaparece. Perder a causa é um dos jeitos mais comuns de uma sessão de depuração ficar mais longa.
Exceções customizadas
Uma exceção customizada é uma classe que estende Exception, para uma checada, ou RuntimeException, para uma não checada. Esta é a versão mínima:
class InsufficientFundsException extends Exception {
InsufficientFundsException(String message) {
super(message);
}
}
void main() {
IO.println(new InsufficientFundsException("short by 30"));
}
O build falha com:
Main.java:1: warning: [serial] serializable class Main.InsufficientFundsException has no definition of serialVersionUID
error: warnings found and -Werror specified
Throwable implementa Serializable, então toda exceção é serializável, e -Xlint:all pede que cada classe serializável declare um número de versão para a forma serializada. O javac simples fica quieto, mas adicionar o campo custa uma linha. Repare também no nome no aviso: Main.InsufficientFundsException. Num arquivo-fonte compacto, toda classe que você declara fica dentro de uma classe escondida Main.
Uma boa exceção customizada carrega os fatos de que quem trata precisa em campos, não só dentro do texto da mensagem:
class InsufficientFundsException extends Exception {
private static final long serialVersionUID = 1L;
final String account;
final long shortByCents;
InsufficientFundsException(String account, long shortByCents) {
super("account " + account + " is short by " + shortByCents + " cents");
this.account = account;
this.shortByCents = shortByCents;
}
}
class Account {
final String id;
long balanceCents;
Account(String id, long balanceCents) {
this.id = id;
this.balanceCents = balanceCents;
}
void withdraw(long cents) throws InsufficientFundsException {
if (cents > balanceCents) {
throw new InsufficientFundsException(id, cents - balanceCents);
}
balanceCents -= cents;
}
}
void main() {
var account = new Account("ACC-7", 1_000);
try {
account.withdraw(400);
account.withdraw(900);
} catch (InsufficientFundsException e) {
IO.println(e.getMessage());
IO.println("offer a top-up of " + e.shortByCents + " cents to " + e.account);
}
IO.println("balance: " + account.balanceCents);
IO.println(new InsufficientFundsException("X", 1));
}
Ele imprime:
account ACC-7 is short by 300 cents
offer a top-up of 300 cents to ACC-7
balance: 600
Main$InsufficientFundsException: account X is short by 1 cents
O código que trata lê e.shortByCents direto. Ele não precisa tirar um número de dentro de uma string. O segundo saque lançou a exceção antes de balanceCents -= cents rodar, então o saldo ficou em 600.
A última linha mostra a classe escondida de novo. O toString de uma exceção usa o nome binário da classe, Main$InsufficientFundsException, e uma exceção não capturada imprime esse nome na stack trace. Num projeto com arquivos de verdade, seria o nome simples da classe.
Ficar sem dinheiro é algo que quem chama consegue tratar, então esta é checada. Para uma regra quebrada dentro do seu próprio código, estenda RuntimeException, e quem chama não precisa de throws. De qualquer jeito, adicione um construtor que receba um Throwable cause se a exceção um dia for encapsular outra.
try-with-resources fecha as coisas para você
Uma instrução try-with-resources declara recursos entre parênteses depois do try e fecha cada um quando o bloco termina, seja como for. Um recurso é qualquer objeto que implementa AutoCloseable, que tem um único método, close(). Arquivos, sockets e conexões de banco de dados implementam essa interface. Para a saída ficar previsível, este post usa uma classe pequena que imprime quando abre e quando fecha:
class Res implements AutoCloseable {
final String name;
Res(String name) {
this.name = name;
IO.println("open " + name);
}
@Override
public void close() {
IO.println("close " + name);
}
}
void main() {
try (var a = new Res("A"); var b = new Res("B")) {
IO.println("using " + a.name + " and " + b.name);
}
IO.println("after the try");
}
Ele imprime:
open A
open B
using A and B
close B
close A
after the try
Os recursos abrem na ordem em que você os declara e fecham na ordem inversa. Isso importa quando um depende do outro. Um buffered writer embrulha um arquivo, então o writer precisa fazer flush e fechar antes que o arquivo por baixo dele feche.
Duas pequenas surpresas apareceram enquanto escrevíamos este exemplo com -Xlint:all:
- Um recurso que você nunca usa gera um aviso. Com o corpo vazio, o javac reporta
auto-closeable resource a is never referenced in body of corresponding try statement. Se você só precisa mesmo abrir e fechar, dê o nome_, como emtry (var _ = new Res("A")), e o aviso some. close()deve declarar só o que lança.AutoCloseable.close()é declarado comthrows Exception. QuandoRes.close()manteve essa cláusula, o javac avisou que elecould throw InterruptedException. A correção é não declarar nada, como acima, ou um tipo específico comoIOException.
O corpo lança uma exceção, e os recursos fecham mesmo assim
Um recurso é fechado mesmo quando o corpo lança uma exceção, e é fechado antes de qualquer catch da mesma instrução rodar:
class Res implements AutoCloseable {
final String name;
Res(String name) {
this.name = name;
IO.println("open " + name);
}
@Override
public void close() {
IO.println("close " + name);
}
}
void main() {
try (var a = new Res("A"); var b = new Res("B")) {
IO.println("using " + a.name + " and " + b.name);
throw new IllegalStateException("disk full");
} catch (IllegalStateException e) {
IO.println("caught: " + e.getMessage());
}
}
Ele imprime:
open A
open B
using A and B
close B
close A
caught: disk full
Os dois recursos foram fechados antes de caught: disk full ser impresso. As variáveis a e b nem estão no escopo dentro do catch, e isso é de propósito: quando ele roda, os recursos já estão fechados.
Vendo os recursos fecharem
A ordem fica mais fácil de ver passo a passo. A animação segue o programa acima, com a saída sendo preenchida à direita:
O try-with-resources abre A e depois B. O corpo lança uma exceção. Antes de a exceção sair do bloco try, B fecha, depois A fecha, e só então o bloco catch recebe a exceção.
Aqui estão esses passos em palavras, caso a animação não rode para você:
- A instrução
trycomeça, e nada está aberto ainda. new Res("A")roda, imprimeopen Ae vira o recursoa.new Res("B")roda, imprimeopen Be vira o recursob.- O corpo imprime
using A and Be depois lançaIllegalStateException. - Antes de a exceção sair do
try, o Java fecha os recursos, começando pelo último aberto.b.close()imprimeclose B. a.close()imprimeclose A.- Com os dois fechados, a exceção chega a
catch (IllegalStateException e), que imprimecaught: disk full.
Quando close() também lança: exceções suprimidas
Se o corpo lança uma exceção e um close() também lança, a exceção do corpo ganha, e as falhas do close são anexadas a ela como exceções suprimidas. Você as lê com getSuppressed():
class Res implements AutoCloseable {
final String name;
Res(String name) {
this.name = name;
IO.println("open " + name);
}
@Override
public void close() {
IO.println("close " + name);
throw new IllegalStateException("close failed: " + name);
}
}
void main() {
try (var a = new Res("A"); var b = new Res("B")) {
IO.println("using " + a.name + " and " + b.name);
throw new RuntimeException("body failed");
} catch (RuntimeException e) {
IO.println("caught: " + e.getMessage());
for (Throwable s : e.getSuppressed()) {
IO.println("suppressed: " + s.getMessage());
}
}
}
Ele imprime:
open A
open B
using A and B
close B
close A
caught: body failed
suppressed: close failed: B
suppressed: close failed: A
O close() de B lançou uma exceção, e o Java chamou o de A mesmo assim. Nada se perdeu. A exceção do corpo costuma ser o problema de verdade, então é ela que você captura, e as falhas do close vão junto, na ordem em que aconteceram. Uma exceção não capturada as imprime na stack trace, embaixo de Suppressed:.
Compare com o jeito antigo, uma chamada a close() dentro de finally. Ali, uma exceção de close() substitui a exceção do corpo, e o problema original desaparece, do mesmo jeito que aconteceu com return dentro de finally.
Fechando uma variável que você já tem
Desde o Java 9, os parênteses podem nomear uma variável existente em vez de declarar uma nova, desde que ela seja final ou efetivamente final, ou seja, nunca reatribuída:
class Res implements AutoCloseable {
final String name;
Res(String name) {
this.name = name;
IO.println("open " + name);
}
@Override
public void close() {
IO.println("close " + name);
}
}
Res openLog() {
return new Res("log");
}
void main() {
Res log = openLog();
try (log; var _ = new Res("lock")) {
IO.println("writing to " + log.name);
}
}
Ele imprime:
open log
open lock
writing to log
close lock
close log
Conferimos a versão com uma classe clássica: javac --release 8 rejeita try (r) com variables in try-with-resources are not supported in -source 8, e --release 9 aceita. Atribua log uma segunda vez e o javac recusa com variable log used as a try-with-resources resource neither final nor effectively final. A regra garante que o objeto fechado é aquele que você quis dizer.
Três hábitos para evitar
A maioria dos bugs com exceções vem de um punhado de hábitos, e o pior deles esconde um bug real atrás de um catch que não diz nada. Este laço soma preços e pula os que não consegue ler:
record Item(String name, String price) {}
int total(List<Item> items) {
int sum = 0;
for (var item : items) {
try {
sum += Integer.parseInt(item.price().strip());
} catch (Exception e) {
// skip prices we can't read
}
}
return sum;
}
void main() {
var items = new ArrayList<Item>();
items.add(new Item("tea", "3"));
items.add(new Item("cake", "4 euros"));
items.add(new Item("coffee", null));
items.add(new Item("water", "2"));
IO.println("total: " + total(items));
}
Ele imprime:
total: 5
"4 euros" lançou NumberFormatException, que é para o que o catch foi escrito. Mas o preço do café é null, então item.price().strip() lançou NullPointerException, um bug diferente, e catch (Exception e) engoliu esse também. O javac não avisou sobre o bloco vazio, nem com -Xlint:all. São três erros em um:
- Um bloco catch vazio. No mínimo, registre no log o que você pulou. Se você quer mesmo ignorar uma exceção, diga por que num comentário e dê à variável o nome
_, como emcatch (NumberFormatException _), para quem lê saber que é de propósito. - Capturar
Exception. Capture o tipo que você espera, aquiNumberFormatException. Aí onullquebraria o programa de forma barulhenta na primeira execução, e alguém corrigiria os dados. - Usar exceções para o fluxo normal de controle. Se preços ruins são uma entrada normal, confira com um
ifantes. Uma exceção deveria significar que algo inesperado aconteceu, e montar uma stack trace para cada linha também dá trabalho extra.
O que lembrar
- Tudo que é lançado estende
Throwable.Erroré para problemas da JVM que você não captura,RuntimeExceptione as subclasses dela são não checadas, e toda outraExceptioné checada. - O compilador obriga você a capturar ou declarar uma exceção checada. Use não checadas para erros de programação e checadas para falhas das quais quem chama pode se recuperar.
- Ordene os blocos
catchdo mais estreito para o mais amplo.finallysempre roda, e umreturndentro dele engole exceções sem avisar. - Lance com uma mensagem que nomeie a regra e o valor ruim, e passe a exceção original como causa quando encapsular.
- Dê a uma exceção customizada um
serialVersionUIDe campos para os fatos de que quem trata precisa. - O try-with-resources fecha os recursos na ordem inversa, antes de o
catchrodar, mesmo quando o corpo lança uma exceção. Falhas declose()viram exceções suprimidas. - Não deixe um bloco catch vazio, e não capture
Exceptionpara silenciar um problema que você não nomeou.
Capture o que você consegue tratar, repasse o que não consegue, e deixe o try-with-resources cuidar do fechamento.