Questions d'entretien Java
Les entretiens Java testent votre profondeur dans les fonctionnalités principales du langage, la conception orientée objet, la compréhension de la concurrence et les capacités de résolution de problèmes. Les ingénieurs seniors sont censés non seulement connaître la syntaxe mais aussi raisonner sur les performances, la mémoire et les meilleures pratiques. Ce guide couvre les questions d'entretien Java les plus courantes et les plus difficiles.
Ce que couvrent les entretiens Java
Java de base et POO
Héritage, polymorphisme, encapsulation, abstraction, interfaces vs classes abstraites, et cycle de vie des objets.
Framework des collections
Internes de HashMap, ConcurrentHashMap, ArrayList vs LinkedList, comparator vs comparable, et itérateurs fail-fast vs fail-safe.
Concurrence et multithreading
Cycle de vie des threads, synchronized, volatile, verrous (ReentrantLock, ReadWriteLock), executors, fork/join et collections concurrentes.
Internes de la JVM et performances
Chargement de classe, modèle mémoire (tas, pile, métaspace), algorithmes de ramasse-miettes (G1, CMS, ZGC), profilage et réglage.
Exemples de questions d'entretien Java
- Expliquez la différence entre classe abstraite et interface en Java. Quand utiliseriez-vous chacun ?Ce qu'une bonne réponse couvre
- Héritage multiple: classe abstraite peut avoir des méthodes concrètes, interface (avant Java 8) que des signatures abstraites.
- État: classe abstraite peut avoir des champs d'instance; interface (avant Java 8) seulement des constantes.
- Constructeurs: classe abstraite a constructeur; interface n'en a pas.
- Java 8+: interfaces peuvent avoir des méthodes par défaut/static; différences s'estompent.
- Utilisation: classe abstraite pour hiérarchie avec état partagé; interface pour contrat de comportement polymorphe.
Voir un exemple de réponse
Une classe abstraite peut contenir des méthodes abstraites et concrètes, des champs d'instance et des constructeurs. Une interface, avant Java 8, ne déclarait que des signatures de méthodes et des constantes. Depuis Java 8, les interfaces peuvent avoir des méthodes par défaut et statiques, réduisant la différence, mais elles ne peuvent toujours pas contenir d'état mutable (champs non statiques) ou de constructeurs. On utilise une classe abstraite quand plusieurs classes partagent un état commun ou un comportement partiel (ex: classe Animal avec des sous-classes Chien, Chat). On utilise une interface pour définir un contrat que toute classe peut implémenter, indépendamment de la hiérarchie (ex: Comparable, Runnable). Les interfaces permettent l'héritage multiple de type, ce qui est impossible avec les classes abstraites. Il faut éviter de forcer l'utilisation d'une classe abstraite si l'état n'est pas nécessaire: préférer l'interface pour la flexibilité. Un piège courant est de vouloir utiliser une interface pour du code partagé: dans ce cas, une classe abstraite ou la composition est plus appropriée.
- Comment fonctionne HashMap en interne ? Comment le redimensionnement affecte-t-il les performances ?Ce qu'une bonne réponse couvre
- HashMap utilise un tableau de buckets (Node[]), chaque bucket est une liste chaînée ou un arbre (si conflit >8).
- Le hashcode de la clé est réduit modulo la capacité pour trouver l'index du bucket.
- L'insertion vérifie d'abord si la clé existe déjà (equals), sinon ajoute en fin de liste/dans l'arbre.
- Le redimensionnement (resize) double la capacité quand le facteur de charge (0.75 par défaut) est dépassé.
- Le redimensionnement recalcule les index (rehash) et répartit les entrées, coût O(n) mais sporadic; impact amorti constant.
Voir un exemple de réponse
En interne, HashMap maintient un tableau de Noeuds (Node<K,V>[]). Chaque Noeud stocke une clé, une valeur, un hash et le prochain Noeud (pour gérer les collisions par chaînage). Lors de l'insertion, le hashCode() de la clé est transformé par une fonction de hachage supplémentaire (pour réduire les collisions) puis l'index est calculé via (n - 1) & hash, où n est la capacité du tableau (puissance de 2). Si deux clés ont le même index, elles sont placées dans la même bucket sous forme de liste chaînée. Si une bucket contient plus de 8 éléments, la liste est convertie en arbre rouge-noir pour améliorer les performances de recherche (O(log n) au lieu de O(n)). Le redimensionnement se produit lorsque la taille dépasse capacity * loadFactor (par défaut 0.75). Il crée un nouveau tableau de capacité double et replace chaque élément en recalculant son index (soit dans la même position, soit décalé de l'ancienne capacité). Cette opération est coûteuse en temps (O(n)) mais elle est rare, donc le coût moyen est constant. Toutefois, si on insère beaucoup d'éléments, les redimensionnements successifs peuvent dégrader les performances. Il est conseillé de spécifier une capacité initiale adaptée pour éviter les redimensionnements fréquents. Un piège est d'utiliser une clé mutable qui modifie son hashCode après insertion, rendant la récupération impossible. En environnement concurrent, HashMap n'est pas thread-safe; utiliser ConcurrentHashMap.
- Écrivez un singleton thread-safe en Java (double-checked locking, méthode Bill Pugh).Ce qu'une bonne réponse couvre
- Double-checked locking: vérification sans verrou, puis synchronisation, puis vérification à nouveau.
- Problème de réorganisation mémoire avant Java 5: nécessite volatile.
- Bill Pugh: utilise une classe interne statique pour le holder, thread-safe sans synchronisation explicite.
- Bill Pugh exploite le mécanisme de chargement paresseux de JVM (classe chargée seulement référencée).
- Les deux approches garantissent un singleton lazy-initialisé thread-safe.
Voir un exemple de réponse
Pour un singleton thread-safe, le double-checked locking (DCL) avec le mot-clé volatile est une solution. Il consiste à vérifier l'instance sans verrou, puis si null, à synchroniser et vérifier à nouveau avant de créer l'instance. Le volatile est nécessaire pour éviter la réorganisation des instructions (l'affectation de l'instance peut se produire avant la construction complète dans les anciennes JVM). Depuis Java 5, volatile rend le DCL correct. La méthode Bill Pugh utilise une classe interne statique (Holder) qui contient l'instance. La JVM charge la classe Holder seulement au premier appel de getInstance(), ce qui garantit l'initialisation paresseuse thread-safe sans synchronisation explicite. Bill Pugh est plus simple et plus performant (pas de synchronisation). Le DCL est utile dans des langages sans mécanisme de classe interne, ou pour rétrocompatibilité. Il faut éviter les singletons non thread-safe (initalisation paresseuse simple) ou les singletons eager (chargés au démarrage si lourds). Le piège avec DCL est d'oublier volatile, menant à des lectures d'instance partiellement construite.
Solution de référencejava // Double-checked locking avec volatile public class SingletonDCL { private static volatile SingletonDCL instance; private SingletonDCL() {} public static SingletonDCL getInstance() { if (instance == null) { synchronized (SingletonDCL.class) { if (instance == null) { instance = new SingletonDCL(); } } } return instance; } } // Bill Pugh (Initialization-on-demand holder) public class SingletonHolder { private SingletonHolder() {} private static class Holder { private static final SingletonHolder INSTANCE = new SingletonHolder(); } public static SingletonHolder getInstance() { return Holder.INSTANCE; } } // Complexité temporelle: O(1) pour getInstance après initialisation. // Complexité spatiale: O(1) pour le singleton lui-même. - Quelle est la différence entre synchronized, ReentrantLock et ReadWriteLock ? Fournissez un cas d'utilisation pour chacun.Ce qu'une bonne réponse couvre
- synchronized: bloc/ méthode, simple, mais contention forte et pas de tentative de verrouillage.
- ReentrantLock: verrou explicite avec tryLock, lockInterruptibly, équité configurable.
- ReadWriteLock: sépare verrou lecture (partagé) et écriture (exclusif), optimise lectures fréquentes.
- synchronized: ne supporte pas le timeout, tryLock, ni les conditions multiples.
- ReentrantLock a des conditions (Condition) semblables à wait/notify.
Voir un exemple de réponse
synchronized est le mécanisme de verrouillage intrinsèque en Java. Il est simple d'utilisation (pas de try/finally nécessaire) mais ne permet pas de tentative de verrouillage avec timeout, ni de verrouillage interruptible, ni de files d'attente équitables. Il est idéal pour des sections critiques courtes et simples. ReentrantLock (de java.util.concurrent.locks) offre plus de flexibilité: tryLock (avec ou sans timeout), lockInterruptibly, et la possibilité de créer des Conditions (pour l'équivalent de wait/notify avec plusieurs files). Il est recommandé pour des scénarios de concurrence avancés, mais nécessite un try/finally pour libérer le verrou. ReadWriteLock permet un accès partagé en lecture et exclusif en écriture: plusieurs threads peuvent lire simultanément, mais un thread en écriture bloque tout le monde. C'est performant quand les lectures sont beaucoup plus fréquentes que les écritures, par exemple dans un cache ou une configuration qui change rarement. Un piège avec ReadWriteLock est la possibilité de famine d'écriture si de nombreuses lectures arrivent. ReentrantLock peut être configuré en mode équitable pour atténuer cela. synchronized est automatiquement équitable (FIFO approximatif).
- Décrivez le modèle mémoire Java. Comment volatile garantit-il la visibilité ?Ce qu'une bonne réponse couvre
- Modèle mémoire Java (JMM) définit les règles de visibilité et d'ordonnancement entre threads.
- Volatile garantit que toute écriture est visible immédiatement par les autres threads (flush mémoire).
- Volatile empêche la réorganisation des instructions par le compilateur et le CPU autour de l'accès.
- Les opérations sur volatile sont atomiques pour les lectures/écritures de 32 bits (pas pour long/double non volatile).
- Volatile ne garantit pas l'atomicité des opérations composées (ex: i++); nécessite synchronisation ou atomics.
Voir un exemple de réponse
Le modèle mémoire Java (JMM) spécifie comment les threads interagissent via la mémoire partagée et quelles optimisations (réorganisation d'instructions, mise en cache) sont autorisées. Il définit la relation 'happens-before' qui garantit la visibilité et l'ordonnancement. volatile est un modificateur qui assure que les accès à une variable se font directement en mémoire principale et ne sont pas réordonnés avec d'autres opérations de synchronisation. Ainsi, une écriture dans une variable volatile est immédiatement visible pour tous les threads (flush des caches). Cela empêche également le compilateur et le CPU de réorganiser les instructions autour de l'accès volatile, ce qui peut casser des hypothèses de concurrence. Néanmoins, volatile ne rend pas une opération composée comme i++ atomique. Pour cela, il faut utiliser synchronized, ReentrantLock ou les classes atomiques (AtomicInteger). Un piège courant est d'utiliser volatile sur une variable double ou long (64 bits) dans une ancienne JVM où l'écriture n'était pas atomique; depuis Java 5, les écritures de long/double sont atomiques si la variable est volatile. En résumé, volatile est utile pour les flags d'état (arrêt, initialisation) mais pas pour les compteurs partagés.
- Écrivez une méthode pour trouver le premier caractère non répété dans une chaîne en utilisant Java Streams ou sans.Ce qu'une bonne réponse couvre
- Parcourir la chaîne, compter les occurrences de chaque caractère (Map<Character, Long>).
- Java Streams: utiliser chars() ou codePoints() pour les caractères Unicode.
- Sans streams: utiliser un HashMap<Character, Integer> et deux passes (ou un LinkedHashMap pour ordre d'insertion).
- Avec streams: filtrer les caractères avec count == 1, trouver le premier dans l'ordre de la chaîne.
- Attention aux caractères Unicode supplémentaires (surrogate pairs): utiliser codePoints() pour les emojis.
Voir un exemple de réponse
Pour trouver le premier caractère non répété, on peut construire une map des fréquences en parcourant la chaîne, puis trouver le premier caractère avec fréquence 1. En Java, on peut utiliser un LinkedHashMap pour préserver l'ordre d'insertion (ou deux passes : construire la map puis itérer la chaîne). Avec les streams, on peut combiner le groupingBy en utilisant LinkedHashMap comme fournisseur de map, puis filtrer. Il faut faire attention aux caractères Unicode hors BMP (comme les emojis) : char en Java est une unité de 16 bits, donc utiliser codePoints() pour obtenir les points de code complets. La complexité est O(n) en temps et O(k) en espace (k distincts). Un piège est de supposer que le caractère est 'char' simple; pour une application internationale, utilisez codePoints(). Aussi, si la chaîne est très longue, une solution basée sur un tableau de taille fixe (par exemple pour ASCII) peut être plus efficace en espace.
Solution de référencejava import java.util.Map; import java.util.LinkedHashMap; import java.util.stream.Collectors; public class FirstNonRepeated { // Avec Java Streams public static Character firstNonRepeatedStream(String s) { Map<Character, Long> freq = s.chars() .mapToObj(c -> (char) c) .collect(Collectors.groupingBy(c -> c, LinkedHashMap::new, Collectors.counting())); return freq.entrySet().stream() .filter(e -> e.getValue() == 1) .map(Map.Entry::getKey) .findFirst() .orElse(null); } // Sans streams (deux passes) public static Character firstNonRepeated(String s) { Map<Character, Integer> countMap = new LinkedHashMap<>(); // conserve l'ordre d'insertion for (char c : s.toCharArray()) { countMap.put(c, countMap.getOrDefault(c, 0) + 1); } for (char c : s.toCharArray()) { if (countMap.get(c) == 1) { return c; } } return null; } // Pour Unicode complet: utiliser codePoints() public static String firstNonRepeatedUnicode(String s) { Map<String, Long> freq = s.codePoints() .mapToObj(Character::toString) .collect(Collectors.groupingBy(c -> c, LinkedHashMap::new, Collectors.counting())); return s.codePoints() .mapToObj(Character::toString) .filter(c -> freq.get(c) == 1) .findFirst() .orElse(null); } } // Complexité temporelle: O(n) où n est la longueur de la chaîne. // Complexité spatiale: O(k) où k est le nombre de caractères distincts (au pire O(n)). - Comment fonctionne le ramasse-miettes en Java ? Comparez G1GC et ZGC.Ce qu'une bonne réponse couvre
- GC automatique: libère la mémoire des objets non atteignables (référencés par le programme).
- Algorithmes: mark-sweep, mark-compact, copying; G1GC est générationnel et partitionné.
- G1GC: divise le heap en régions, collecte les régions les plus remplies de déchets (garbage-first); pauses prévisibles.
- ZGC: non générationnel, utilise des pointeurs colorés et le remappage mémoire, pauses inférieures à 10ms.
- ZGC gère jusqu'à des très grandes heaps (To) avec très peu de pause; G1GC convient pour des heaps < 100 Go environ.
Voir un exemple de réponse
Le ramasse-miettes (GC) en Java gère automatiquement la mémoire en identifiant les objets qui ne sont plus référencés (garbage) et en libérant leur espace. Plusieurs algorithmes existent: le mark-sweep (marquage puis balayage), mark-compact (compaction pour réduire la fragmentation), et copying (copie des objets vivants dans une zone propre). G1GC (Garbage First) est un collecteur générationnel par défaut depuis Java 9. Il divise le heap en régions de taille fixe (1-32 Mo) et collecte d'abord les régions contenant le plus de déchets, ce qui permet de contrôler les durées de pause. Il donne des objectifs de temps de pause (soft real-time) et convient pour des heaps jusqu'à environ 100 Go. ZGC (Z Garbage Collector) est un collecteur non générationnel introduit en Java 11 (expérimental) et stable depuis Java 15. Il utilise des pointeurs colorés (colored pointers) et le remappage mémoire pour effectuer la majorité du travail en concurrence avec les threads applicatifs, ce qui maintient les pauses très courtes (<10ms) même pour des heaps de plusieurs téraoctets. ZGC est idéal pour les applications nécessitant de très faibles latences et de grandes mémoires, mais son débit peut être légèrement inférieur à G1GC pour les applications avec beaucoup d'allocations. Le choix dépend des exigences: G1GC pour des pauses contrôlées, ZGC pour des heaps très grandes et latence ultra-basse.
- Implémentez une file d'attente bloquante personnalisée en utilisant wait-notify (ou les conditions de Lock).Ce qu'une bonne réponse couvre
- File bloquante: les threads attendant de prendre (take) si vide se bloquent; les threads offrant (put) si pleine se bloquent.
- Utiliser un verrou (Lock) et deux conditions (Condition) : notFull et notEmpty.
- Méthode put: acquérir le verrou, tant que plein attendre notFull, ajouter, signaler notEmpty.
- Méthode take: acquérir le verrou, tant que vide attendre notEmpty, retirer, signaler notFull.
- Gestion de l'interruption: utiliser lock.lockInterruptibly() et conditions.await() qui lance InterruptedException.
Voir un exemple de réponse
Pour implémenter une file bloquante personnalisée, on utilise un verrou (ReentrantLock) et deux Conditions pour gérer les situations de file pleine (notFull) et file vide (notEmpty). La méthode put acquiert le verrou (lockInterruptibly pour être interruptible), puis vérifie si la file est pleine; si oui, elle attend sur notFull. Quand la file n'est plus pleine, elle ajoute l'élément et signale notEmpty pour réveiller un thread en attente de take. La méthode take fait l'inverse: attend sur notEmpty si vide, puis retire un élément et signale notFull. Il faut toujours utiliser un while pour la condition d'attente (évite les réveils intempestifs). Les opérations de la queue sous-jacente (LinkedList) sont O(1) pour add/poll. Il est important de gérer les interruptions: await() lance InterruptedException, donc la méthode doit la propager. Une alternative serait d'utiliser wait/notify sur un objet synchronisé, mais les Conditions de Lock offrent plus de flexibilité et de performance. Un piège courant est de signaler le mauvais signal (signaler notFull sur un take), ce qui ne réveillerait pas les threads en attente de put. Finalement, assurez-vous de libérer le verrou dans un bloc finally pour éviter les deadlocks.
Solution de référencejava import java.util.LinkedList; import java.util.Queue; import java.util.concurrent.locks.Condition; import java.util.concurrent.locks.ReentrantLock; public class CustomBlockingQueue<T> { private final Queue<T> queue = new LinkedList<>(); private final int capacity; private final ReentrantLock lock = new ReentrantLock(); private final Condition notEmpty = lock.newCondition(); private final Condition notFull = lock.newCondition(); public CustomBlockingQueue(int capacity) { this.capacity = capacity; } public void put(T item) throws InterruptedException { lock.lockInterruptibly(); try { while (queue.size() == capacity) { notFull.await(); // attend que la file ne soit plus pleine } queue.add(item); notEmpty.signal(); // signale aux threads en attente de take } finally { lock.unlock(); } } public T take() throws InterruptedException { lock.lockInterruptibly(); try { while (queue.isEmpty()) { notEmpty.await(); // attend que la file ne soit plus vide } T item = queue.poll(); notFull.signal(); // signale aux threads en attente de put return item; } finally { lock.unlock(); } } // Optionnel: size() et reste public int size() { lock.lock(); try { return queue.size(); } finally { lock.unlock(); } } } // Complexité temporelle: O(1) pour put et take (opérations de queue). // Complexité spatiale: O(capacity) pour les éléments stockés.
Comment se préparer
- Entraînez-vous à écrire du code propre et thread-safe sur un tableau blanc ou un éditeur en ligne.
- Comprenez les internes de la JVM et soyez capable de raisonner sur la mémoire et le comportement du GC.
- Maîtrisez le framework des collections : connaissez les compromis de chaque implémentation.
- Révisez les fonctionnalités Java 8+ : streams, lambdas, optional, CompletableFuture et la nouvelle API Date/Heure.
- Préparez une réponse approfondie pour 'En quoi HashMap est-il différent de ConcurrentHashMap ?' avec des détails internes.
Questions fréquemment posées
Les fonctionnalités de Java 8 sont-elles importantes pour les entretiens ?
Oui, la plupart des entretiens supposent la connaissance des lambdas, streams, optionals et CompletableFuture. Ils sont largement utilisés dans le développement Java moderne.
Dois-je mémoriser les flags de réglage de la JVM ?
Vous devez être familier avec les flags GC courants comme -XX:+UseG1GC, -Xms, -Xmx, et savoir comment ajuster les tailles de tas. Comprendre le raisonnement est plus important que la mémorisation.
À quelle fréquence les patrons de conception sont-ils demandés dans les entretiens Java ?
Fréquemment, surtout Singleton, Factory, Builder et Observer. Soyez prêt à implémenter un Singleton thread-safe ou à expliquer le pattern Strategy avec les lambdas Java 8.
Quelle est la différence entre les itérateurs fail-fast et fail-safe ?
Les itérateurs fail-fast lancent une ConcurrentModificationException si la collection est structurellement modifiée pendant l'itération (par exemple, ArrayList). Les itérateurs fail-safe opèrent sur un clone (par exemple, ConcurrentHashMap, CopyOnWriteArrayList).
Dois-je connaître les fonctionnalités de Java 9-17 pour les entretiens ?
Oui, attendez-vous à des questions sur les modules (Jigsaw), les classes scellées, les records, le pattern matching pour instanceof et les blocs de texte. Au moins soyez conscient des fonctionnalités clés.
Pratiquez les questions Java avec des retours instantanés de l'IA
Téléchargez votre CV, obtenez un entretien simulé personnalisé et voyez exactement ce qu'il faut améliorer — gratuit pour commencer.