Programação Java

Exceções em Java: checked vs unchecked, try-with-resources e bom design de erros

Iniciante9 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

Exceções são a forma de um método Java avisar que não conseguiu cumprir o que prometia. Este guia explica a hierarquia e as regras de propagação, quando usar exceções checked ou unchecked, como o try-with-resources fecha recursos corretamente, como projetar seus próprios tipos de exceção e como registrar e traduzir erros sem perder a causa. Você vai conseguir escrever um tratamento de erros que falha de forma visível, limpa e uma única vez.

O que você vai aprender

  • Explicar a hierarquia de Throwable e como uma exceção se propaga pela pilha de chamadas
  • Escolher entre exceções checked e unchecked para uma determinada falha
  • Usar corretamente try-with-resources e multi-catch, incluindo exceções suprimidas
  • Projetar exceções customizadas que carregam contexto e preservam a causa
  • Evitar os antipadrões clássicos: engolir exceções, capturar Throwable, usar exceções como controle de fluxo

Uma exceção é o sinal de que um método não conseguiu cumprir seu contrato, levado pilha de chamadas acima até que algo a trate. O design do Java tem duas características marcantes: exceções são objetos comuns, com uma hierarquia de classes, e o compilador exige o tratamento de um dos ramos dessa hierarquia. As duas coisas são simples de enunciar e fáceis de usar mal, e o uso incorreto tem custo real: erros engolidos que reaparecem semanas depois como dados corrompidos, stack traces que apontam para o lugar errado, recursos vazando a cada falha. Este guia cobre a mecânica, as decisões de design e os padrões que se sustentam em produção. O código foi escrito para o Java 21.

A hierarquia e o que cada ramo significa

Throwable
├── Error                      JVM-level failures: OutOfMemoryError, StackOverflowError, LinkageError
└── Exception
    ├── RuntimeException       unchecked: NullPointerException, IllegalArgumentException,
    │                          IllegalStateException, IndexOutOfBoundsException, ArithmeticException
    └── (everything else)      checked: IOException, SQLException, InterruptedException, ParseException

Errors sinalizam condições das quais uma aplicação não deve tentar se recuperar. Capturar OutOfMemoryError e seguir em frente raramente faz sentido, porque a JVM pode já estar em um estado inconsistente.

Exceções checked estendem Exception, mas não RuntimeException. Um método que pode lançar uma delas precisa declará-la com throws, e quem chama precisa capturá-la ou declará-la também. O compilador garante isso, e esse é justamente o objetivo: a falha faz parte da assinatura do método, e quem chama não pode ignorá-la por descuido.

Exceções unchecked estendem RuntimeException. Podem ser lançadas em qualquer lugar sem declaração e, por convenção, representam erros de programação: um null onde era necessário um objeto, um argumento fora do intervalo, um método chamado no estado errado. Não se espera que quem chama as capture; a correção é consertar o bug.

O próprio Throwable carrega uma mensagem, uma causa, um stack trace capturado na construção e uma lista de exceções suprimidas. É a captura do stack trace que torna as exceções caras em comparação com um valor de retorno (percorrer a pilha custa microssegundos, não nanossegundos), e é por isso que exceções devem marcar situações excepcionais, e não resultados esperados.

Propagação: try, catch, finally

Quando uma instrução lança uma exceção, a execução abandona o restante do bloco e a JVM procura nas instruções try que a envolvem, da mais interna para fora, um catch cujo tipo corresponda. Se nenhum corresponder no método atual, o método termina abruptamente e a busca continua em quem o chamou. Uma exceção que chega ao topo de uma thread sem handler encerra essa thread, depois que o handler de exceções não capturadas da thread imprime o trace; se era a última thread não daemon, a JVM encerra.

static int parsePort(String raw) {
    try {
        int port = Integer.parseInt(raw);
        if (port < 1 || port > 65_535) {
            throw new IllegalArgumentException("port out of range: " + port);
        }
        return port;
    } catch (NumberFormatException e) {
        throw new IllegalArgumentException("port is not a number: " + raw, e);   // translate, keep cause
    } finally {
        System.out.println("parsePort finished");                                // runs either way
    }
}

As cláusulas catch são testadas em ordem, e colocar uma cláusula de um supertipo antes da de um subtipo é erro de compilação, porque a segunda nunca poderia ser executada. O multi-catch (catch (IOException | SQLException e)) trata vários tipos sem relação entre si em um único bloco; a variável é implicitamente final.

O finally é executado em todos os casos: quando o bloco try termina normalmente, quando faz return e quando lança uma exceção. Ele serve para a limpeza que precisa acontecer de qualquer jeito. Duas regras o mantêm seguro: nunca faça return dentro de um finally (isso descarta silenciosamente qualquer exceção pendente) e nunca deixe o finally lançar exceção (ela substitui a original e esconde a falha real).

try-with-resources

Antes do Java 7, fechar um stream corretamente exigia um try/finally aninhado com verificações de null, e mesmo um código cuidadoso perdia a exceção original se close() também falhasse. O try-with-resources cuida de tudo isso: qualquer objeto que implemente AutoCloseable declarado entre os parênteses é fechado automaticamente ao fim do bloco, na ordem inversa da declaração.

Path source = Path.of("input.csv");
Path target = Path.of("output.csv");

try (BufferedReader in = Files.newBufferedReader(source);
     BufferedWriter out = Files.newBufferedWriter(target)) {
    String line;
    while ((line = in.readLine()) != null) {
        out.write(line.toUpperCase());
        out.newLine();
    }
} catch (IOException e) {
    throw new UncheckedIOException("Failed to copy " + source + " to " + target, e);
}

Se o bloco lançar uma exceção e close() também lançar, a exceção do bloco se propaga e a falha do fechamento é anexada a ela via addSuppressed; você pode inspecioná-la com getSuppressed(). O Java 9 passou a permitir usar diretamente uma variável efetivamente final declarada antes: try (existingConnection) { ... }.

Implemente AutoCloseable nas suas próprias classes que mantêm um recurso (uma conexão, um lock, um diretório temporário) e documente se close() é idempotente. Closeable é a subinterface específica de I/O, cujo close() lança IOException e precisa ser idempotente.

Escolhendo entre checked e unchecked

Essa é a decisão de design que divide os desenvolvedores Java. A orientação dos autores da linguagem é clara: exceções checked para condições das quais se pode razoavelmente esperar que quem chama se recupere, unchecked para erros de programação. Na prática, três forças empurram o código moderno para as unchecked:

  1. Lambdas e streams. Interfaces funcionais como Function e Supplier não declaram exceções checked, então uma exceção checked dentro de uma lambda precisa ser capturada e encapsulada ali mesmo. O guia de streams mostra como isso fica poluído.
  2. Camadas. Uma SQLException vinda da camada de persistência não significa nada para um controller web. Cada camada teria que declará-la ou encapsulá-la, e a poluição das assinaturas se espalha pela aplicação inteira.
  3. Frameworks. A hierarquia DataAccessException do Spring, a PersistenceException do JPA e a maioria dos clientes HTTP são unchecked, e o Kotlin, que roda na mesma JVM, abandonou totalmente as exceções checked.

Uma política que funciona: manter exceções checked nas bordas, onde existe uma recuperação específica (FileNotFoundException quando foi o usuário que escolheu o arquivo, InterruptedException em código concorrente), e traduzir todo o resto em exceções unchecked com um tipo descritivo assim que o erro entra no seu código. A UncheckedIOException existe no JDK exatamente para essa tradução.

SituaçãoO que usar
Quem chama passou um argumento inválidoIllegalArgumentException (ou NullPointerException via Objects.requireNonNull)
O objeto não está no estado certo para a chamadaIllegalStateException
Operação não suportada por esta implementaçãoUnsupportedOperationException
Um recurso externo falhou e uma nova tentativa ou alternativa é plausívelChecked, ou um tipo unchecked específico do domínio na fronteira
Regra de negócio violada (saldo insuficiente)Exceção de domínio, geralmente unchecked, ou um tipo resultado
Bug detectado internamenteAssertionError ou IllegalStateException

Projetando suas próprias exceções

Uma exceção customizada justifica sua existência quando quem chama precisa capturá-la separadamente ou quando ela carrega um contexto que um tipo genérico não consegue carregar. Mantenha a classe pequena, estenda a base certa e ofereça os construtores padrão para que sempre seja possível anexar uma causa:

public class PaymentDeclinedException extends RuntimeException {
    private final String paymentId;
    private final String declineCode;

    public PaymentDeclinedException(String paymentId, String declineCode, Throwable cause) {
        super("Payment %s declined with code %s".formatted(paymentId, declineCode), cause);
        this.paymentId = paymentId;
        this.declineCode = declineCode;
    }

    public String paymentId()   { return paymentId; }
    public String declineCode() { return declineCode; }
}

Os campos permitem que um handler aja sobre dados estruturados (mapear um código de recusa para um status HTTP, decidir se vale tentar de novo) sem precisar interpretar a mensagem. Uma hierarquia ajuda quando os handlers querem granularidades diferentes: PaymentException como base, PaymentDeclinedException e PaymentGatewayUnavailableException como folhas; quem chama pode capturar a base ou uma folha.

Dê às exceções nomes com o sufixo Exception e descreva o problema, não a camada ("OrderNotFoundException", não "RepositoryException"). Escreva uma mensagem útil para quem vai ler um log às 3 da manhã: inclua identificadores e valores, nunca dados sensíveis como senhas ou números completos de cartão.

Encadeamento e tradução

Quando você captura uma exceção de baixo nível e lança uma de nível mais alto, passe a original como causa. O stack trace passa então a imprimir seções Caused by: até o fundo, o que muitas vezes é a única forma de encontrar a falha real. Lançar uma nova exceção sem a causa é a pior coisa que você pode fazer com a depuração futura.

A tradução deve acontecer uma única vez, na fronteira da camada. Se cada método encapsula de novo, o trace vira uma cebola de cinco camadas com mensagens idênticas.

Tratamento: onde e como

Um bom tratamento segue uma regra prática: capture tarde, lance cedo. Lance assim que detectar o problema (valide os argumentos no início do método) e capture apenas onde puder fazer algo útil. As opções úteis são:

  • Recuperar: tentar de novo, usar um valor padrão, recorrer a um cache.
  • Traduzir: encapsular em um tipo que quem chama a camada atual entenda, com a causa.
  • Reportar e parar: em uma fronteira (handler HTTP, listener de mensagens, main), registrar com o trace completo e converter em uma resposta ou código de saída.

Todo o resto (registrar e relançar, capturar e retornar null, blocos catch vazios) só piora as coisas. Registrar e relançar produz o mesmo trace duas vezes, uma em cada camada; registre apenas na fronteira.

// Boundary handler: the one place that catches broadly
public HttpResponse handle(HttpRequest request) {
    try {
        return service.process(request);
    } catch (PaymentDeclinedException e) {
        return HttpResponse.status(402).body(e.declineCode());      // expected domain outcome
    } catch (RuntimeException e) {
        log.error("Unhandled failure for request {}", request.id(), e);   // full trace, once
        return HttpResponse.status(500).body("internal error");
    }
}

Passe o objeto da exceção como último argumento do logger, e não e.getMessage(): os frameworks de logging só imprimem o trace completo quando recebem o Throwable.

InterruptedException

A InterruptedException merece uma regra própria. Ela é lançada por métodos bloqueantes (sleep, join, BlockingQueue.take) quando outra thread pede que esta pare. Engoli-la cancela esse pedido. Se você não puder propagá-la, restaure a flag com Thread.currentThread().interrupt(); e encerre o trabalho o mais rápido possível. O guia de concorrência cobre o protocolo.

Exceções e desempenho

Construir uma exceção preenche o stack trace, o que percorre todos os frames da thread atual. Em uma pilha de chamadas profunda, isso leva dezenas de microssegundos: desprezível para uma falha genuína, desastroso em um loop que lança exceção para sinalizar "não encontrado". Quando uma exceção precisa mesmo ser barata (o sinal de controle interno de uma biblioteca, por exemplo), as opções são reutilizar uma instância pré-alocada ou sobrescrever fillInStackTrace para retornar this; as duas trocam capacidade de depuração por velocidade e pertencem a código de biblioteca, não à lógica da aplicação.

O compilador JIT também otimiza um método quente que lança repetidamente, descartando o stack trace depois de um tempo (-XX:-OmitStackTraceInFastThrow desativa isso). É por isso que um log de produção pode de repente mostrar uma NullPointerException sem trace nenhum. Se você vir isso, a exceção já foi lançada milhares de vezes.

Erros comuns

Blocos catch vazios. catch (Exception e) {} transforma uma falha em corrupção silenciosa de dados. Se a exceção puder mesmo ser ignorada, explique o motivo em um comentário e registre-a em nível debug.

Capturar Throwable ou Error. Você vai capturar OutOfMemoryError e StackOverflowError e continuar com uma JVM que pode estar em estado inconsistente. No máximo, capture Exception nas fronteiras.

Perder a causa. throw new ServiceException("failed") dentro de um catch, sem o e. O motivo real some.

Fazer return dentro do finally. Isso sobrescreve tanto o valor de retorno quanto qualquer exceção em andamento.

Exceções como códigos de retorno. Lançar UserNotFoundException em uma busca que normalmente não encontra nada metade das vezes. Retorne Optional<User> no lugar.

throws Exception amplo demais. Não diz nada a quem chama e o força a usar catches igualmente amplos. Declare tipos específicos ou use exceções unchecked.

Registrar em todas as camadas. Uma falha, seis stack traces no log e alguém percorrendo todos eles à meia-noite.

Validar tarde demais. Um argumento null guardado em um campo e desreferenciado três chamadas depois produz um trace que aponta longe do bug. Objects.requireNonNull(x, "x") no início do método aponta o verdadeiro culpado.

Principais pontos

  • Exceções checked fazem parte da assinatura de um método e obrigam quem chama a decidir; as unchecked sinalizam bugs ou falhas que quem chama não consegue corrigir. O código moderno pende para as unchecked, traduzindo nas fronteiras entre camadas.
  • O try-with-resources fecha recursos AutoCloseable na ordem inversa e preserva a exceção original, anexando as falhas de close() como suprimidas.
  • Nunca faça return nem lance exceção dentro do finally; isso esconde a exceção que importava.
  • Sempre passe a exceção original como causa ao traduzir, e traduza uma vez por camada, não a cada chamada.
  • Capture tarde, em uma fronteira capaz de recuperar, traduzir ou reportar; registre ali o Throwable completo e em nenhum outro lugar.
  • Exceções capturam um stack trace na construção; use valores de retorno ou Optional para resultados esperados.

Fontes e leituras complementares

Perguntas frequentes

Devo usar exceções checked ou unchecked?
Use uma exceção checked quando quem chama puder se recuperar de fato e você quiser que o compilador o obrigue a tomar uma decisão: um arquivo inexistente que o usuário pode selecionar de novo, um timeout que vale a pena tentar outra vez. Use uma unchecked para erros de programação e para falhas que nada no ponto de chamada consegue corrigir. A maioria das bibliotecas e frameworks modernos pende para unchecked, porque exceções checked não se combinam bem com lambdas e código genérico.
O que acontece se o bloco try e o bloco finally lançarem exceções?
A exceção do finally substitui a do try, que se perde. Por isso um bloco finally não deve conter código capaz de lançar exceções. O try-with-resources resolve isso para recursos: a exceção original prevalece e qualquer falha de close() é anexada a ela como exceção suprimida.
É errado capturar Exception?
Em uma fronteira (um handler de requisições, o método run de uma thread, uma tarefa agendada), capturar Exception para registrar e converter em resposta é o certo, porque deixá-la escapar mataria a thread ou mostraria um stack trace ao usuário. No meio do código de negócio isso esconde bugs: capture os tipos específicos que você sabe tratar e deixe o resto se propagar.
Por que não devo usar exceções como controle de fluxo?
Criar uma exceção captura o stack trace, o que custa muito mais que uma verificação booleana, e deixa o código mais difícil de ler, porque o caminho normal fica escondido em um catch. Para situações esperadas, use um valor de retorno, um Optional ou uma validação, e reserve as exceções para os casos que o método não conseguiu tratar.