Code Java bogué : les 10 erreurs les plus courantes commises par les développeurs Java
Publié: 2022-03-11Java est un langage de programmation qui a été initialement développé pour la télévision interactive, mais au fil du temps, il s'est répandu partout où les logiciels peuvent être utilisés. Conçu avec la notion de programmation orientée objet, abolissant les complexités d'autres langages tels que C ou C++, le ramasse-miettes et une machine virtuelle architecturalement agnostique, Java a créé une nouvelle façon de programmer. De plus, il a une courbe d'apprentissage douce et semble adhérer avec succès à sa propre devise - "Écrire une fois, courir partout", ce qui est presque toujours vrai ; mais les problèmes Java sont toujours présents. Je vais aborder dix problèmes Java qui, à mon avis, sont les erreurs les plus courantes.
Erreur courante #1 : Négliger les bibliothèques existantes
C'est certainement une erreur pour les développeurs Java d'ignorer la quantité innombrable de bibliothèques écrites en Java. Avant de réinventer la roue, essayez de rechercher les bibliothèques disponibles - beaucoup d'entre elles ont été peaufinées au fil des années de leur existence et sont libres d'utilisation. Il peut s'agir de bibliothèques de journalisation, comme logback et Log4j, ou de bibliothèques liées au réseau, comme Netty ou Akka. Certaines des bibliothèques, telles que Joda-Time, sont devenues un standard de facto.
Ce qui suit est une expérience personnelle d'un de mes projets précédents. La partie du code responsable de l'échappement HTML a été écrite à partir de zéro. Il a bien fonctionné pendant des années, mais il a finalement rencontré une entrée utilisateur qui l'a fait tourner dans une boucle infinie. L'utilisateur, constatant que le service ne répondait pas, a tenté de réessayer avec la même entrée. Finalement, tous les processeurs du serveur alloués à cette application étaient occupés par cette boucle infinie. Si l'auteur de cet outil d'échappement HTML naïf avait décidé d'utiliser l'une des bibliothèques bien connues disponibles pour l'échappement HTML, comme HtmlEscapers de Google Guava, cela ne serait probablement pas arrivé. À tout le moins, vrai pour la plupart des bibliothèques populaires avec une communauté derrière elle, l'erreur aurait été trouvée et corrigée plus tôt par la communauté pour cette bibliothèque.
Erreur courante n° 2 : manque le mot-clé « break » dans un bloc Switch-Case
Ces problèmes Java peuvent être très embarrassants et restent parfois inconnus jusqu'à leur exécution en production. Le comportement de secours dans les instructions switch est souvent utile ; cependant, manquer un mot-clé "break" lorsqu'un tel comportement n'est pas souhaité peut conduire à des résultats désastreux. Si vous avez oublié de mettre un "break" dans "case 0" dans l'exemple de code ci-dessous, le programme écrira "Zero" suivi de "One", puisque le flux de contrôle à l'intérieur ici passera par l'intégralité de l'instruction "switch" jusqu'à ce que il atteint une "pause". Par exemple:
public static void switchCasePrimer() { int caseIndex = 0; switch (caseIndex) { case 0: System.out.println("Zero"); case 1: System.out.println("One"); break; case 2: System.out.println("Two"); break; default: System.out.println("Default"); } }
Dans la plupart des cas, la solution la plus propre consisterait à utiliser le polymorphisme et à déplacer le code avec des comportements spécifiques dans des classes distinctes. Les erreurs Java telles que celle-ci peuvent être détectées à l'aide d'analyseurs de code statiques, par exemple FindBugs et PMD.
Erreur courante #3 : Oublier de libérer des ressources
Chaque fois qu'un programme ouvre un fichier ou une connexion réseau, il est important pour les débutants en Java de libérer la ressource une fois que vous avez fini de l'utiliser. Des précautions similaires doivent être prises si une exception devait être levée lors d'opérations sur de telles ressources. On pourrait soutenir que le FileInputStream a un finaliseur qui invoque la méthode close() sur un événement de récupération de place ; cependant, comme nous ne pouvons pas être sûrs du début d'un cycle de récupération de place, le flux d'entrée peut consommer des ressources informatiques pendant une période indéfinie. En fait, il existe une instruction vraiment utile et intéressante introduite dans Java 7 en particulier pour ce cas, appelée try-with-resources :
private static void printFileJava7() throws IOException { try(FileInputStream input = new FileInputStream("file.txt")) { int data = input.read(); while(data != -1){ System.out.print((char) data); data = input.read(); } } }
Cette instruction peut être utilisée avec n'importe quel objet qui implémente l'interface AutoClosable. Il garantit que chaque ressource est fermée à la fin de l'instruction.
Erreur courante n° 4 : fuites de mémoire
Java utilise la gestion automatique de la mémoire, et même si c'est un soulagement d'oublier d'allouer et de libérer de la mémoire manuellement, cela ne signifie pas qu'un développeur Java débutant ne devrait pas savoir comment la mémoire est utilisée dans l'application. Des problèmes d'allocation de mémoire sont toujours possibles. Tant qu'un programme crée des références à des objets qui ne sont plus nécessaires, il ne sera pas libéré. D'une certaine manière, nous pouvons encore appeler cette fuite de mémoire. Les fuites de mémoire en Java peuvent se produire de différentes manières, mais la raison la plus courante est les références d'objets éternelles, car le ramasse-miettes ne peut pas supprimer les objets du tas tant qu'il y a encore des références à eux. On peut créer une telle référence en définissant la classe avec un champ statique contenant une collection d'objets, et en oubliant de définir ce champ statique sur null une fois que la collection n'est plus nécessaire. Les champs statiques sont considérés comme des racines GC et ne sont jamais collectés.
Une autre raison potentielle derrière de telles fuites de mémoire est un groupe d'objets se référençant les uns aux autres, provoquant des dépendances circulaires afin que le ramasse-miettes ne puisse pas décider si ces objets avec des références de dépendance croisée sont nécessaires ou non. Un autre problème concerne les fuites dans la mémoire hors tas lorsque JNI est utilisé.
L'exemple de fuite primitive pourrait ressembler à ceci :
final ScheduledExecutorService scheduledExecutorService = Executors.newScheduledThreadPool(1); final Deque<BigDecimal> numbers = new LinkedBlockingDeque<>(); final BigDecimal divisor = new BigDecimal(51); scheduledExecutorService.scheduleAtFixedRate(() -> { BigDecimal number = numbers.peekLast(); if (number != null && number.remainder(divisor).byteValue() == 0) { System.out.println("Number: " + number); System.out.println("Deque size: " + numbers.size()); } }, 10, 10, TimeUnit.MILLISECONDS); scheduledExecutorService.scheduleAtFixedRate(() -> { numbers.add(new BigDecimal(System.currentTimeMillis())); }, 10, 10, TimeUnit.MILLISECONDS); try { scheduledExecutorService.awaitTermination(1, TimeUnit.DAYS); } catch (InterruptedException e) { e.printStackTrace(); }
Cet exemple crée deux tâches planifiées. La première tâche prend le dernier nombre d'une deque appelée "nombres" et imprime le nombre et la taille de la deque au cas où le nombre est divisible par 51. La deuxième tâche met des nombres dans la deque. Les deux tâches sont planifiées à un rythme fixe et s'exécutent toutes les 10 ms. Si le code est exécuté, vous verrez que la taille du deque augmente en permanence. Cela finira par remplir le deque d'objets consommant toute la mémoire de tas disponible. Pour éviter cela tout en préservant la sémantique de ce programme, on peut utiliser une méthode différente pour prendre les nombres du deque : « pollLast ». Contrairement à la méthode "peekLast", "pollLast" renvoie l'élément et le supprime de la deque alors que "peekLast" ne renvoie que le dernier élément.
Pour en savoir plus sur les fuites de mémoire en Java, veuillez vous référer à notre article qui a démystifié ce problème.
Erreur courante n° 5 : Allocation excessive de déchets
Une allocation de mémoire excessive peut se produire lorsque le programme crée un grand nombre d'objets de courte durée. Le ramasse-miettes fonctionne en continu, supprimant les objets inutiles de la mémoire, ce qui a un impact négatif sur les performances des applications. Un exemple simple :
String oneMillionHello = ""; for (int i = 0; i < 1000000; i++) { oneMillionHello = oneMillionHello + "Hello!"; } System.out.println(oneMillionHello.substring(0, 6));
Dans le développement Java, les chaînes sont immuables. Ainsi, à chaque itération, une nouvelle chaîne est créée. Pour résoudre ce problème, nous devrions utiliser un StringBuilder mutable :
StringBuilder oneMillionHelloSB = new StringBuilder(); for (int i = 0; i < 1000000; i++) { oneMillionHelloSB.append("Hello!"); } System.out.println(oneMillionHelloSB.toString().substring(0, 6));
Alors que la première version nécessite un peu de temps pour s'exécuter, la version qui utilise StringBuilder produit un résultat en beaucoup moins de temps.
Erreur courante #6 : Utiliser des références nulles sans besoin
Éviter l'utilisation excessive de null est une bonne pratique. Par exemple, il est préférable de renvoyer des tableaux ou des collections vides à partir de méthodes plutôt que des valeurs nulles, car cela peut aider à empêcher NullPointerException.
Considérez la méthode suivante qui traverse une collection obtenue à partir d'une autre méthode, comme indiqué ci-dessous :
List<String> accountIds = person.getAccountIds(); for (String accountId : accountIds) { processAccount(accountId); }
Si getAccountIds() renvoie null lorsqu'une personne n'a pas de compte, alors NullPointerException sera déclenché. Pour résoudre ce problème, une vérification nulle sera nécessaire. Cependant, si au lieu d'un null, il renvoie une liste vide, alors NullPointerException n'est plus un problème. De plus, le code est plus propre puisque nous n'avons pas besoin de vérifier la variable accountIds.
Pour traiter d'autres cas où l'on veut éviter les valeurs nulles, différentes stratégies peuvent être utilisées. L'une de ces stratégies consiste à utiliser le type Optional qui peut être soit un objet vide, soit un wrap d'une certaine valeur :
Optional<String> optionalString = Optional.ofNullable(nullableString); if(optionalString.isPresent()) { System.out.println(optionalString.get()); }
En fait, Java 8 fournit une solution plus concise :
Optional<String> optionalString = Optional.ofNullable(nullableString); optionalString.ifPresent(System.out::println);
Le type optionnel fait partie de Java depuis la version 8, mais il est bien connu depuis longtemps dans le monde de la programmation fonctionnelle. Avant cela, il était disponible dans Google Guava pour les versions antérieures de Java.
Erreur courante #7 : Ignorer les exceptions
Il est souvent tentant de ne pas gérer les exceptions. Cependant, la meilleure pratique pour les développeurs Java débutants et expérimentés consiste à les gérer. Les exceptions sont levées exprès, donc dans la plupart des cas, nous devons résoudre les problèmes à l'origine de ces exceptions. Ne négligez pas ces événements. Si nécessaire, vous pouvez le relancer, afficher une boîte de dialogue d'erreur à l'utilisateur ou ajouter un message au journal. À tout le moins, il convient d'expliquer pourquoi l'exception n'a pas été gérée afin d'informer les autres développeurs de la raison.
selfie = person.shootASelfie(); try { selfie.show(); } catch (NullPointerException e) { // Maybe, invisible man. Who cares, anyway? }
Une façon plus claire de mettre en évidence l'insignifiance d'une exception est d'encoder ce message dans le nom de la variable de l'exception, comme ceci :
try { selfie.delete(); } catch (NullPointerException unimportant) { }
Erreur courante n° 8 : Exception de modification simultanée
Cette exception se produit lorsqu'une collection est modifiée lors d'une itération sur celle-ci à l'aide de méthodes autres que celles fournies par l'objet itérateur. Par exemple, nous avons une liste de chapeaux et nous voulons supprimer tous ceux qui ont des oreillettes :
List<IHat> hats = new ArrayList<>(); hats.add(new Ushanka()); // that one has ear flaps hats.add(new Fedora()); hats.add(new Sombrero()); for (IHat hat : hats) { if (hat.hasEarFlaps()) { hats.remove(hat); } }
Si nous exécutons ce code, "ConcurrentModificationException" sera levé puisque le code modifie la collection tout en l'itérant. La même exception peut se produire si l'un des multiples threads travaillant avec la même liste essaie de modifier la collection tandis que d'autres itèrent dessus. La modification simultanée de collections dans plusieurs threads est une chose naturelle, mais doit être traitée avec les outils habituels de la boîte à outils de programmation simultanée tels que les verrous de synchronisation, les collections spéciales adoptées pour la modification simultanée, etc. Il existe des différences subtiles dans la manière dont ce problème Java peut être résolu. dans les cas à thread unique et les cas multithread. Vous trouverez ci-dessous une brève discussion de certaines manières de gérer cela dans un scénario à thread unique :

Collectez des objets et supprimez-les dans une autre boucle
Récupérer les chapeaux avec oreillettes dans une liste pour les retirer plus tard à l'intérieur d'une autre boucle est une solution évidente, mais nécessite une collecte supplémentaire pour stocker les chapeaux à retirer :
List<IHat> hatsToRemove = new LinkedList<>(); for (IHat hat : hats) { if (hat.hasEarFlaps()) { hatsToRemove.add(hat); } } for (IHat hat : hatsToRemove) { hats.remove(hat); }
Utiliser la méthode Iterator.remove
Cette approche est plus concise et ne nécessite pas la création d'une collection supplémentaire :
Iterator<IHat> hatIterator = hats.iterator(); while (hatIterator.hasNext()) { IHat hat = hatIterator.next(); if (hat.hasEarFlaps()) { hatIterator.remove(); } }
Utiliser les méthodes de ListIterator
L'utilisation de l'itérateur de liste est appropriée lorsque la collection modifiée implémente l'interface List. Les itérateurs qui implémentent l'interface ListIterator prennent en charge non seulement les opérations de suppression, mais également les opérations d'ajout et de définition. ListIterator implémente l'interface Iterator afin que l'exemple ressemble presque à la méthode Iterator remove. La seule différence est le type d'itérateur de chapeau et la manière dont nous obtenons cet itérateur avec la méthode "listIterator()". L'extrait ci-dessous montre comment remplacer chaque chapeau avec des oreillettes par des sombreros en utilisant les méthodes "ListIterator.remove" et "ListIterator.add":
IHat sombrero = new Sombrero(); ListIterator<IHat> hatIterator = hats.listIterator(); while (hatIterator.hasNext()) { IHat hat = hatIterator.next(); if (hat.hasEarFlaps()) { hatIterator.remove(); hatIterator.add(sombrero); } }
Avec ListIterator, les appels de méthode remove et add peuvent être remplacés par un seul appel à set :
IHat sombrero = new Sombrero(); ListIterator<IHat> hatIterator = hats.listIterator(); while (hatIterator.hasNext()) { IHat hat = hatIterator.next(); if (hat.hasEarFlaps()) { hatIterator.set(sombrero); // set instead of remove and add } }
Utiliser les méthodes de flux introduites dans Java 8 Avec Java 8, les programmeurs ont la possibilité de transformer une collection en un flux et de filtrer ce flux selon certains critères. Voici un exemple de la façon dont l'api de flux pourrait nous aider à filtrer les chapeaux et à éviter "ConcurrentModificationException".
hats = hats.stream().filter((hat -> !hat.hasEarFlaps())) .collect(Collectors.toCollection(ArrayList::new));
La méthode "Collectors.toCollection" créera une nouvelle ArrayList avec des chapeaux filtrés. Cela peut être un problème si la condition de filtrage devait être satisfaite par un grand nombre d'éléments, résultant en une grande ArrayList ; il doit donc être utilisé avec précaution. Utiliser la méthode List.removeIf présentée en Java 8 Une autre solution disponible en Java 8, et clairement la plus concise, est l'utilisation de la méthode « removeIf » :
hats.removeIf(IHat::hasEarFlaps);
C'est ça. Sous le capot, il utilise "Iterator.remove" pour accomplir le comportement.
Utiliser des collections spécialisées
Si au tout début nous avions décidé d'utiliser "CopyOnWriteArrayList" au lieu de "ArrayList", alors il n'y aurait eu aucun problème, car "CopyOnWriteArrayList" fournit des méthodes de modification (telles que définir, ajouter et supprimer) qui ne changent pas le tableau de sauvegarde de la collection, mais créez plutôt une nouvelle version modifiée de celui-ci. Cela permet une itération sur la version originale de la collection et des modifications sur celle-ci en même temps, sans risque de "ConcurrentModificationException". L'inconvénient de cette collection est évident - génération d'une nouvelle collection à chaque modification.
Il existe d'autres collections adaptées à différents cas, par exemple "CopyOnWriteSet" et "ConcurrentHashMap".
Une autre erreur possible avec les modifications de collection simultanées consiste à créer un flux à partir d'une collection et, pendant l'itération du flux, à modifier la collection de sauvegarde. La règle générale pour les flux est d'éviter la modification de la collection sous-jacente lors de l'interrogation du flux. L'exemple suivant montre une manière incorrecte de gérer un flux :
List<IHat> filteredHats = hats.stream().peek(hat -> { if (hat.hasEarFlaps()) { hats.remove(hat); } }).collect(Collectors.toCollection(ArrayList::new));
La méthode peek rassemble tous les éléments et effectue l'action prévue sur chacun d'eux. Ici, l'action tente de supprimer des éléments de la liste sous-jacente, ce qui est erroné. Pour éviter cela, essayez certaines des méthodes décrites ci-dessus.
Erreur courante n° 9 : rompre des contrats
Parfois, le code fourni par la bibliothèque standard ou par un fournisseur tiers repose sur des règles qui doivent être respectées pour que les choses fonctionnent. Par exemple, il peut s'agir du contrat hashCode et equals qui, lorsqu'il est suivi, garantit le fonctionnement d'un ensemble de collections du framework de collections Java et d'autres classes qui utilisent les méthodes hashCode et equals. Désobéir aux contrats n'est pas le genre d'erreur qui entraîne toujours des exceptions ou interrompt la compilation du code ; c'est plus délicat, car parfois cela modifie le comportement de l'application sans aucun signe de danger. Un code erroné pourrait se glisser dans la version de production et provoquer tout un tas d'effets indésirables. Cela peut inclure un mauvais comportement de l'interface utilisateur, des rapports de données erronés, des performances d'application médiocres, une perte de données, etc. Heureusement, ces bugs désastreux ne se produisent pas très souvent. J'ai déjà mentionné le hashCode et le contrat égal. Il est utilisé dans les collections qui reposent sur le hachage et la comparaison d'objets, comme HashMap et HashSet. En termes simples, le contrat contient deux règles :
- Si deux objets sont égaux, alors leurs codes de hachage doivent être égaux.
- Si deux objets ont le même code de hachage, ils peuvent être égaux ou non.
La rupture de la première règle du contrat entraîne des problèmes lors de la tentative de récupération d'objets à partir d'un hashmap. La deuxième règle signifie que les objets avec le même code de hachage ne sont pas nécessairement égaux. Examinons les effets de la violation de la première règle :
public static class Boat { private String name; Boat(String name) { this.name = name; } @Override public boolean equals(Object o) { if (this == o) return true; if (o == null || getClass() != o.getClass()) return false; Boat boat = (Boat) o; return !(name != null ? !name.equals(boat.name) : boat.name != null); } @Override public int hashCode() { return (int) (Math.random() * 5000); } }
Comme vous pouvez le voir, la classe Boat a remplacé les méthodes equals et hashCode. Cependant, il a rompu le contrat, car hashCode renvoie des valeurs aléatoires pour le même objet à chaque fois qu'il est appelé. Le code suivant ne trouvera probablement pas de bateau nommé "Enterprise" dans le hashset, malgré le fait que nous ayons ajouté ce type de bateau plus tôt :
public static void main(String[] args) { Set<Boat> boats = new HashSet<>(); boats.add(new Boat("Enterprise")); System.out.printf("We have a boat named 'Enterprise' : %b\n", boats.contains(new Boat("Enterprise"))); }
Un autre exemple de contrat implique la méthode de finalisation. Voici une citation de la documentation officielle de Java décrivant sa fonction :
Le contrat général de finalize est qu'il est invoqué si et quand la machine virtuelle JavaTM a déterminé qu'il n'y a plus aucun moyen d'accéder à cet objet par n'importe quel thread (qui n'est pas encore mort), sauf à la suite d'un action entreprise par la finalisation d'un autre objet ou d'une classe qui est prête à être finalisée. La méthode finalize peut prendre n'importe quelle action, y compris rendre cet objet à nouveau disponible pour d'autres threads ; cependant, le but habituel de finalize est d'effectuer des actions de nettoyage avant que l'objet ne soit irrévocablement supprimé. Par exemple, la méthode finalize d'un objet qui représente une connexion d'entrée/sortie peut effectuer des transactions d'E/S explicites pour interrompre la connexion avant que l'objet ne soit définitivement supprimé.
On pourrait décider d'utiliser la méthode finalize pour libérer des ressources comme les gestionnaires de fichiers, mais ce serait une mauvaise idée. En effet, il n'y a aucune garantie de temps sur le moment où finalize sera invoqué, puisqu'il est invoqué pendant le ramasse-miettes, et le temps de GC est indéterminable.
Erreur courante n° 10 : utiliser un type brut au lieu d'un type paramétré
Les types bruts, selon les spécifications Java, sont des types qui ne sont pas paramétrés ou des membres non statiques de la classe R qui ne sont pas hérités de la superclasse ou de la superinterface de R. Il n'y avait pas d'alternative aux types bruts jusqu'à ce que les types génériques soient introduits en Java. . Il prend en charge la programmation générique depuis la version 1.5, et les génériques ont sans aucun doute été une amélioration significative. Cependant, pour des raisons de rétrocompatibilité, un piège a été laissé qui pourrait potentiellement casser le système de type. Regardons l'exemple suivant :
List listOfNumbers = new ArrayList(); listOfNumbers.add(10); listOfNumbers.add("Twenty"); listOfNumbers.forEach(n -> System.out.println((int) n * 2));
Ici, nous avons une liste de nombres définis comme une ArrayList brute. Comme son type n'est pas spécifié avec le paramètre type, nous pouvons y ajouter n'importe quel objet. Mais dans la dernière ligne, nous transtypons les éléments en int, le doublons et affichons le nombre doublé sur la sortie standard. Ce code se compilera sans erreur, mais une fois exécuté, il déclenchera une exception d'exécution car nous avons tenté de convertir une chaîne en entier. Évidemment, le système de type est incapable de nous aider à écrire du code sûr si nous lui cachons les informations nécessaires. Pour résoudre le problème, nous devons spécifier le type d'objets que nous allons stocker dans la collection :
List<Integer> listOfNumbers = new ArrayList<>(); listOfNumbers.add(10); listOfNumbers.add("Twenty"); listOfNumbers.forEach(n -> System.out.println((int) n * 2));
La seule différence avec l'original est la ligne définissant la collection :
List<Integer> listOfNumbers = new ArrayList<>();
Le code fixe ne compilerait pas car nous essayons d'ajouter une chaîne dans une collection censée stocker uniquement des entiers. Le compilateur affichera une erreur et pointera sur la ligne où nous essayons d'ajouter la chaîne "Twenty" à la liste. C'est toujours une bonne idée de paramétrer les types génériques. De cette façon, le compilateur est capable d'effectuer toutes les vérifications de type possibles et les risques d'exceptions d'exécution causées par des incohérences du système de type sont minimisés.
Conclusion
Java en tant que plate-forme simplifie de nombreuses choses dans le développement de logiciels, en s'appuyant à la fois sur une JVM sophistiquée et sur le langage lui-même. Cependant, ses fonctionnalités, telles que la suppression de la gestion manuelle de la mémoire ou des outils POO décents, n'éliminent pas tous les problèmes et problèmes auxquels un développeur Java ordinaire est confronté. Comme toujours, les connaissances, la pratique et les didacticiels Java comme celui-ci sont les meilleurs moyens d'éviter et de résoudre les erreurs d'application - alors connaissez vos bibliothèques, lisez Java, lisez la documentation JVM et écrivez des programmes. N'oubliez pas non plus les analyseurs de code statiques, car ils pourraient indiquer les bogues réels et mettre en évidence les bogues potentiels.