Programmation Java
Les exceptions en Java : checked ou unchecked, try-with-resources et bonne conception
Débutant9 min de lectureMis à jour
Cette page a été traduite de l'anglais avec l'aide de l'IA, puis vérifiée par rapport à l'original. Lire la version anglaise
Les exceptions sont le moyen pour une méthode Java de signaler qu'elle n'a pas pu tenir sa promesse. Ce guide explique la hiérarchie et les règles de propagation, quand choisir une exception checked ou unchecked, comment try-with-resources ferme correctement les ressources, comment concevoir vos propres types d'exception et comment journaliser et traduire les erreurs sans perdre la cause. Vous saurez écrire une gestion d'erreurs qui échoue bruyamment, proprement et une seule fois.
Ce que vous allez apprendre
- Expliquer la hiérarchie de Throwable et la manière dont une exception remonte la pile d'appels
- Choisir entre exception checked et unchecked selon l'échec à signaler
- Utiliser correctement try-with-resources et le multi-catch, y compris les exceptions supprimées
- Concevoir des exceptions personnalisées qui portent du contexte et conservent la cause
- Éviter les anti-patterns classiques : avaler l'exception, attraper Throwable, se servir des exceptions comme flux de contrôle
Une exception est le signal qu'une méthode n'a pas pu remplir son contrat ; elle remonte la pile d'appels jusqu'à ce que quelque chose la traite. La conception de Java a deux traits distinctifs : les exceptions sont des objets ordinaires organisés en hiérarchie de classes, et le compilateur impose leur traitement pour une branche de cette hiérarchie. Les deux s'énoncent simplement et sont faciles à mal employer, avec des conséquences bien réelles : des erreurs avalées qui ressurgissent des semaines plus tard sous forme de données corrompues, des stack traces qui pointent au mauvais endroit, des ressources qui fuient à chaque échec. Ce guide couvre les mécanismes, les choix de conception et les patterns qui tiennent en production. Le code cible Java 21.
La hiérarchie et le sens de chaque branche
Throwable
├── Error JVM-level failures: OutOfMemoryError, StackOverflowError, LinkageError
└── Exception
├── RuntimeException unchecked: NullPointerException, IllegalArgumentException,
│ IllegalStateException, IndexOutOfBoundsException, ArithmeticException
└── (everything else) checked: IOException, SQLException, InterruptedException, ParseException
Les Errors signalent des conditions dont une application ne doit pas chercher à se relever. Attraper OutOfMemoryError et continuer a rarement du sens, car la JVM est peut-être déjà dans un état incohérent.
Les exceptions checked étendent Exception mais pas RuntimeException. Une méthode susceptible d'en lever une doit la déclarer avec throws, et l'appelant doit soit l'attraper, soit la déclarer à son tour. Le compilateur y veille, et c'est tout l'intérêt : l'échec fait partie de la signature de la méthode, et les appelants ne peuvent pas l'ignorer par inadvertance.
Les exceptions unchecked étendent RuntimeException. On peut les lever n'importe où sans les déclarer, et elles représentent par convention des erreurs de programmation : un null là où un objet était attendu, un argument hors bornes, une méthode appelée dans le mauvais état. L'appelant n'est pas censé les attraper ; la solution consiste à corriger le bug.
Throwable lui-même porte un message, une cause, une stack trace capturée à la construction et une liste d'exceptions supprimées. C'est la capture de la stack trace qui rend les exceptions coûteuses par rapport à une valeur de retour (parcourir la pile se compte en microsecondes, pas en nanosecondes), et c'est pour cela que les exceptions doivent marquer des situations exceptionnelles plutôt que des résultats attendus.
Propagation : try, catch, finally
Quand une instruction lève une exception, l'exécution abandonne le reste du bloc et la JVM parcourt les instructions try englobantes, de la plus interne à la plus externe, à la recherche d'un catch de type compatible. Si aucun ne convient dans la méthode courante, celle-ci se termine brutalement et la recherche se poursuit chez l'appelant. Une exception qui atteint le sommet d'un thread sans trouver de handler termine ce thread, après que son gestionnaire d'exceptions non attrapées a affiché la trace ; s'il s'agissait du dernier thread non daemon, la JVM s'arrête.
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
}
}
Les clauses catch sont essayées dans l'ordre, et placer une clause pour un supertype avant celle d'un sous-type provoque une erreur de compilation, puisque la seconde ne pourrait jamais s'exécuter. Le multi-catch (catch (IOException | SQLException e)) traite plusieurs types sans lien entre eux dans un seul bloc ; la variable est implicitement final.
finally s'exécute que le bloc try se termine normalement, qu'il fasse un return ou qu'il lève une exception. Il sert au nettoyage qui doit avoir lieu quoi qu'il arrive. Deux règles le rendent sûr : ne jamais faire de return dans un finally (cela jette silencieusement toute exception en cours), et ne jamais laisser finally lever d'exception (elle remplacerait l'exception d'origine et masquerait la véritable panne).
try-with-resources
Avant Java 7, fermer correctement un flux exigeait un try/finally imbriqué avec des tests de null, et même un code soigné perdait l'exception d'origine si close() échouait aussi. try-with-resources prend tout cela en charge : tout objet implémentant AutoCloseable déclaré entre les parenthèses est fermé automatiquement après le bloc, dans l'ordre inverse de déclaration.
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);
}
Si le bloc lève une exception et que close() en lève une aussi, c'est l'exception du bloc qui se propage, et l'échec de fermeture lui est rattaché via addSuppressed ; vous pouvez l'examiner avec getSuppressed(). Depuis Java 9, une variable effectivement finale déclarée plus haut peut être utilisée directement : try (existingConnection) { ... }.
Implémentez AutoCloseable sur vos propres classes qui détiennent une ressource (une connexion, un verrou, un répertoire temporaire) et documentez si close() est idempotente. Closeable est la sous-interface propre aux E/S, dont la méthode close() lève IOException et doit être idempotente.
Choisir entre checked et unchecked
C'est la décision de conception qui divise les développeurs Java. La ligne directrice des auteurs du langage est claire : des exceptions checked pour les conditions dont on peut raisonnablement attendre de l'appelant qu'il se relève, des unchecked pour les erreurs de programmation. En pratique, trois forces poussent le code moderne vers l'unchecked :
- Les lambdas et les streams. Les interfaces fonctionnelles comme
FunctionetSupplierne déclarent aucune exception checked : une exception checked levée dans une lambda doit donc être attrapée et encapsulée sur place. Le guide sur les streams montre à quel point cela devient verbeux. - Le découpage en couches. Une
SQLExceptionissue de la couche de persistance ne signifie rien pour un contrôleur web. Chaque couche devrait la déclarer ou l'encapsuler, si bien que la pollution des signatures gagne toute l'application. - Les frameworks. La hiérarchie
DataAccessExceptionde Spring, laPersistenceExceptionde JPA et la plupart des clients HTTP sont unchecked, et Kotlin, qui tourne sur la même JVM, a purement et simplement abandonné les exceptions checked.
Une politique qui fonctionne : garder les exceptions checked en périphérie, là où une reprise précise existe (FileNotFoundException quand c'est l'utilisateur qui a choisi le fichier, InterruptedException dans le code concurrent), et traduire tout le reste en exceptions unchecked au type explicite dès que l'erreur entre dans votre code. UncheckedIOException existe dans le JDK précisément pour cette traduction.
| Situation | À utiliser |
|---|---|
| L'appelant a passé un argument invalide | IllegalArgumentException (ou NullPointerException via Objects.requireNonNull) |
| L'objet n'est pas dans le bon état pour cet appel | IllegalStateException |
| Opération non prise en charge par cette implémentation | UnsupportedOperationException |
| Une ressource externe a échoué et une nouvelle tentative ou une alternative est envisageable | Checked, ou un type unchecked propre au domaine à la frontière |
| Règle métier enfreinte (solde insuffisant) | Exception métier, généralement unchecked, ou un type résultat |
| Bug détecté en interne | AssertionError ou IllegalStateException |
Concevoir vos propres exceptions
Une exception personnalisée justifie son existence quand les appelants ont besoin de l'attraper séparément ou quand elle porte un contexte qu'un type générique ne peut pas porter. Gardez la classe minimale, étendez la bonne classe de base et fournissez les constructeurs standard afin de toujours pouvoir rattacher une cause :
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; }
}
Les champs permettent à un handler d'agir sur des données structurées (associer un code de refus à un statut HTTP, décider s'il faut réessayer) sans analyser le message. Une hiérarchie est utile quand les handlers veulent des granularités différentes : PaymentException comme base, PaymentDeclinedException et PaymentGatewayUnavailableException comme feuilles ; un appelant peut attraper la base ou une feuille.
Suffixez le nom de vos exceptions par Exception et décrivez le problème, pas la couche (« OrderNotFoundException », pas « RepositoryException »). Rendez le message utile à la personne qui lira les logs à 3 heures du matin : incluez identifiants et valeurs, jamais de données sensibles comme des mots de passe ou des numéros de carte complets.
Chaînage et traduction
Quand vous attrapez une exception de bas niveau pour en lever une de plus haut niveau, passez l'originale comme cause. La stack trace affiche alors des sections Caused by: jusqu'en bas, et c'est souvent le seul moyen de trouver la vraie panne. Lever une nouvelle exception sans la cause est de loin ce que vous pouvez faire de pire pour les débogages à venir.
La traduction doit se faire une seule fois, à la frontière de la couche. Si chaque méthode réencapsule, la trace devient un oignon de cinq couches aux messages identiques.
Traitement : où et comment
Une bonne gestion suit une règle simple : attraper tard, lever tôt. Levez l'exception dès que vous détectez le problème (validez les arguments en tête de méthode) et n'attrapez qu'aux endroits où vous pouvez faire quelque chose d'utile. Les options utiles sont :
- Se relever : réessayer, utiliser une valeur par défaut, se rabattre sur un cache.
- Traduire : encapsuler dans un type que comprennent les appelants de la couche courante, avec la cause.
- Signaler et s'arrêter : à une frontière (handler HTTP, listener de messages,
main), journaliser avec la trace complète et convertir en réponse ou en code de sortie.
Tout le reste (journaliser puis relancer, attraper et renvoyer null, les blocs catch vides) ne fait qu'empirer les choses. Journaliser et relancer produit la même trace deux fois, une par couche ; journalisez uniquement à la frontière.
// 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");
}
}
Passez l'objet exception comme dernier argument du logger, et non e.getMessage() : les frameworks de logging n'affichent la trace complète que lorsqu'ils reçoivent le Throwable.
InterruptedException
InterruptedException mérite sa propre règle. Elle est levée par les méthodes bloquantes (sleep, join, BlockingQueue.take) quand un autre thread demande à celui-ci de s'arrêter. L'avaler revient à annuler cette demande. Si vous ne pouvez pas la propager, restaurez le flag avec Thread.currentThread().interrupt();, puis quittez le traitement le plus vite possible. Le guide sur la concurrence détaille ce protocole.
Exceptions et performances
Construire une exception remplit la stack trace, ce qui parcourt toutes les frames du thread courant. Dans une pile d'appels profonde, cela représente des dizaines de microsecondes : négligeable pour une vraie panne, ruineux dans une boucle qui lève une exception pour signaler « introuvable ». Quand une exception doit vraiment être bon marché (le signal de contrôle interne d'une bibliothèque, par exemple), on peut réutiliser une instance préallouée ou redéfinir fillInStackTrace pour qu'elle renvoie this ; les deux sacrifient le débogage à la vitesse et ont leur place dans du code de bibliothèque, pas dans la logique applicative.
Le compilateur JIT optimise aussi une méthode chaude qui lève sans cesse la même exception en supprimant la stack trace au bout d'un moment (-XX:-OmitStackTraceInFastThrow désactive ce comportement). Voilà pourquoi un log de production peut soudain afficher une NullPointerException sans aucune trace. Si vous voyez cela, l'exception a déjà été levée des milliers de fois.
Erreurs fréquentes
Les blocs catch vides. catch (Exception e) {} transforme un échec en corruption silencieuse des données. Si l'exception peut vraiment être ignorée, expliquez pourquoi dans un commentaire et journalisez-la au niveau debug.
Attraper Throwable ou Error. Vous attraperez OutOfMemoryError et StackOverflowError et continuerez avec une JVM potentiellement incohérente. Attrapez au plus Exception, et seulement aux frontières.
Perdre la cause. throw new ServiceException("failed") dans un catch, sans e. La vraie raison a disparu.
Faire un return dans finally. Il écrase à la fois la valeur de retour et toute exception en cours.
Les exceptions comme codes de retour. Lever UserNotFoundException depuis une recherche dont on sait qu'elle ne trouvera rien une fois sur deux. Renvoyez plutôt un Optional<User>.
Un throws Exception trop large. Il n'apprend rien aux appelants et les force à des catch tout aussi larges. Déclarez des types précis ou utilisez des exceptions unchecked.
Journaliser à chaque couche. Un seul échec, six stack traces dans les logs, et quelqu'un qui les fait défiler à minuit.
Valider trop tard. Un argument null stocké dans un champ puis déréférencé trois appels plus loin produit une trace qui pointe loin du bug. Objects.requireNonNull(x, "x") en tête de méthode désigne le vrai coupable.
Points clés à retenir
- Les exceptions checked font partie de la signature d'une méthode et obligent l'appelant à trancher ; les unchecked signalent des bugs ou des échecs que l'appelant ne peut pas corriger. Le code moderne penche pour l'unchecked, avec une traduction aux frontières des couches.
- try-with-resources ferme les ressources
AutoCloseabledans l'ordre inverse et préserve l'exception d'origine, en y rattachant les échecs declose()comme exceptions supprimées. - Ne faites jamais de return ni ne levez d'exception dans
finally: cela masque l'exception qui comptait. - Passez toujours l'exception d'origine comme cause lors d'une traduction, et traduisez une fois par couche, pas à chaque appel.
- Attrapez tard, à une frontière capable de se relever, de traduire ou de signaler ; journalisez-y le
Throwablecomplet, et nulle part ailleurs. - Les exceptions capturent une stack trace à leur construction ; pour les résultats attendus, utilisez des valeurs de retour ou
Optional.
Sources et lectures complémentaires
Questions fréquentes
- Faut-il utiliser des exceptions checked ou unchecked ?
- Prenez une exception checked quand l'appelant peut vraiment se relever et que vous voulez que le compilateur l'oblige à trancher : un fichier introuvable que l'utilisateur peut sélectionner à nouveau, un timeout qui mérite une nouvelle tentative. Prenez une exception unchecked pour les erreurs de programmation et pour les échecs que rien, à l'endroit de l'appel, ne peut corriger. La plupart des bibliothèques et frameworks modernes penchent pour l'unchecked, car les exceptions checked se combinent mal avec les lambdas et le code générique.
- Que se passe-t-il si le bloc try et le bloc finally lèvent tous deux une exception ?
- L'exception levée dans finally remplace celle du try, qui est perdue. C'est pourquoi un bloc finally ne doit contenir aucun code susceptible de lever une exception. try-with-resources règle le problème pour les ressources : l'exception d'origine l'emporte et tout échec de close() lui est rattaché comme exception supprimée.
- Est-ce une mauvaise pratique d'attraper Exception ?
- À une frontière (un handler de requête, la méthode run d'un thread, une tâche planifiée), attraper Exception pour journaliser puis produire une réponse est la bonne approche, car la laisser s'échapper tuerait le thread ou afficherait une stack trace à l'utilisateur. Au cœur du code métier, cela masque des bugs : attrapez les types précis que vous savez traiter et laissez le reste remonter.
- Pourquoi ne pas utiliser les exceptions comme flux de contrôle ?
- Créer une exception capture la stack trace, ce qui coûte bien plus cher qu'un test booléen, et le code devient plus difficile à lire parce que le chemin normal se cache dans un catch. Pour les situations attendues, utilisez une valeur de retour, un Optional ou une validation, et réservez les exceptions aux cas que la méthode n'a pas pu gérer.