String #
En Java, le type String représente une séquence de caractères. Il est très utilisé pour manipuler du texte : noms, messages, fichiers, etc. Une particularité essentielle à comprendre est que les objets de type String sont immuables : une fois créés, ils ne peuvent pas être modifiés. Toute opération qui semble modifier une chaîne (comme la concaténation, le remplacement ou la suppression de caractères) crée en réalité un nouvel objet String en mémoire, sans changer l’original.
Par exemple :
String s = "Bonjour";
s = s + " le monde"; // Crée un nouvel objet String
Ici, la chaîne “Bonjour” n’est pas modifiée : une nouvelle chaîne “Bonjour le monde” est créée et la variable s pointe vers ce nouvel objet. L’ancienne chaîne reste inchangée (et sera éventuellement libérée par le ramasse-miettes).
Cette immuabilité rend les String sûres et efficaces pour le partage, mais peut entraîner des problèmes de performance si on fait beaucoup de modifications : dans ce cas, il vaut mieux utiliser StringBuilder.
En Java, les chaînes de caractères (String) sont représentées en mémoire selon l’encodage UTF-16. Cela signifie que chaque élément du tableau interne d’une chaîne est un « code unit » de 16 bits (un char Java), mais tous les caractères Unicode ne tiennent pas forcément dans un seul char.
L’UTF-16 est un encodage qui permet de représenter tous les caractères Unicode. La plupart des caractères courants (latin, accentués, etc.) sont codés sur un seul char (16 bits), mais certains caractères spéciaux ou emojis, appelés « supplémentaires », nécessitent deux char consécutifs (appelés une paire de substitution ou surrogate pair).
La méthode charAt(int index) retourne le char à la position donnée dans la chaîne, mais ce char ne correspond pas toujours à un caractère complet pour l’utilisateur. Si la chaîne contient un caractère supplémentaire (hors du plan multilingue de base), charAt peut retourner seulement une partie de ce caractère (un des deux éléments de la paire de substitution).
Pour manipuler correctement les caractères Unicode, il faut utiliser les méthodes codePointAt, codePoints() ou les classes de l’API Character, qui tiennent compte des paires de substitution et permettent de traiter chaque caractère Unicode comme une entité logique.
String s = "A😊B";
System.out.println(s.length()); // Affiche 4 (car 😊 occupe deux char)
System.out.println(s.charAt(1)); // Affiche un char de la paire surrogate, pas le smiley complet
System.out.println(s.codePointAt(1));// Affiche le code Unicode complet du smiley
Ainsi, il faut être vigilant lors du traitement de chaînes contenant des emojis ou des caractères spéciaux, car la longueur d’une chaîne (length) et l’accès par charAt ne correspondent pas toujours au nombre réel de caractères.
Utilisez l’application suivante pour explorer la représentation des chaînes de caractères en format UTF-16. Vous pouvez taper des caractères et voir comment la chaîne de caractère est représentée en mémoire.
Voici un exemple en Java qui illustre la plupart des propriétés et méthodes de la classe String.
La méthode split de la classe String en Java est utilisée pour diviser une chaîne en un tableau de sous-chaînes en fonction d’un délimiteur spécifié, qui peut être une chaîne simple ou une expression régulière. Par exemple, split("\\s+") divise une chaîne sur un ou plusieurs espaces, tandis que split(",") utilise une virgule comme séparateur. L’expression \\s+ signifie ‘un ou plusieurs espaces.
Nous pourrions utiliser split(";") pour diviser sur un point-virgule.
Nous donnons plus loin un aperçu des expressions régulières. La notion n’est toutefois pas approfondie dans ce cours.
Les principales méthodes de la classe String #
La classe String offre plusieurs dizaines de méthodes. Les tableaux qui suivent
décrivent les plus courantes et indiquent, lorsque la question se pose, leur complexité
algorithmique, c’est-à-dire l’ordre de grandeur du nombre d’opérations effectuées.
Dans tout ce qui suit :
- \(n\) désigne la longueur de la chaîne sur laquelle la méthode est appelée ;
- \(m\) désigne la longueur de la chaîne passée en argument, lorsqu’il y en a une.
Rappelons qu’intuitivement \(O(1)\) signifie « un temps constant, indépendant de la taille de la chaîne », et que \(O(n)\) signifie « un temps proportionnel à la longueur de la chaîne ».
Accéder au contenu #
| Méthode | Description | Complexité |
|---|---|---|
length() | Nombre de char (unités de code UTF-16) de la chaîne. La valeur est stockée dans l’objet : elle n’est pas recalculée. | \(O(1)\) |
isEmpty() | Vrai si la chaîne ne contient aucun caractère. Équivaut à length() == 0. | \(O(1)\) |
isBlank() | Vrai si la chaîne est vide ou ne contient que des espaces. Il faut parcourir la chaîne. | \(O(n)\) |
charAt(int i) | Le char à la position i. Un accès direct dans un tableau. Attention : ce n’est pas toujours un caractère complet (voir les paires de substitution). | \(O(1)\) |
codePointAt(int i) | Le point de code Unicode complet commençant à la position i. | \(O(1)\) |
toCharArray() | Copie la chaîne dans un nouveau tableau de char. | \(O(n)\) |
chars(), codePoints() | Produisent un flux (IntStream) des unités de code ou des points de code. Créer le flux est immédiat ; le parcourir coûte \(O(n)\). | \(O(1)\) puis \(O(n)\) |
hashCode() | Valeur de hachage utilisée par HashMap et HashSet. Elle est calculée une seule fois puis conservée dans l’objet. | \(O(n)\) au premier appel, \(O(1)\) ensuite |
Comparer #
| Méthode | Description | Complexité |
|---|---|---|
equals(Object o) | Compare le contenu de deux chaînes. C’est la bonne façon de comparer des chaînes : l’opérateur == compare les références, pas le texte. Si les longueurs diffèrent, la réponse est immédiate. | \(O(n)\) au pire |
equalsIgnoreCase(String s) | Comme equals, mais sans tenir compte de la casse. | \(O(n)\) |
compareTo(String s) | Comparaison lexicographique (ordre du dictionnaire selon les valeurs Unicode). Retourne un entier négatif, nul ou positif. Utilisée pour trier. | \(O(\min(n,m))\) |
compareToIgnoreCase(String s) | Comme compareTo, sans tenir compte de la casse. | \(O(\min(n,m))\) |
startsWith(String p), endsWith(String p) | Vrai si la chaîne commence (ou se termine) par p. Une seule comparaison alignée : pas de recherche. | \(O(m)\) |
contentEquals(CharSequence cs) | Compare le contenu avec n’importe quelle séquence de caractères, par exemple un StringBuilder. | \(O(n)\) |
Rechercher #
| Méthode | Description | Complexité |
|---|---|---|
indexOf(int c), lastIndexOf(int c) | Position de la première (ou dernière) occurrence d’un caractère, ou -1. Un simple balayage. | \(O(n)\) |
indexOf(String s), lastIndexOf(String s) | Position de la première (ou dernière) occurrence d’une sous-chaîne, ou -1. | \(O(n\,m)\) |
indexOf(String s, int depart) | Comme ci-dessus, mais la recherche commence à la position depart. Pratique pour énumérer toutes les occurrences. | idem |
contains(CharSequence cs) | Vrai si la sous-chaîne apparaît. Équivaut à indexOf(cs.toString()) >= 0 et hérite donc de son coût. | \(O(n\,m)\) |
Transformer #
Toutes ces méthodes retournent une nouvelle chaîne : la chaîne d’origine n’est jamais modifiée.
| Méthode | Description | Complexité |
|---|---|---|
substring(int a), substring(int a, int b) | Extrait les caractères de a (inclus) à b (exclu). Le contenu est copié : le coût est proportionnel à la longueur extraite. | \(O(b-a)\) |
concat(String s) et l’opérateur + | Assemble deux chaînes dans une nouvelle chaîne. Concaténer dans une boucle est donc coûteux : préférez StringBuilder. | \(O(n+m)\) |
replace(char a, char b) | Remplace toutes les occurrences d’un caractère par un autre. Un seul balayage. | \(O(n)\) |
replace(CharSequence a, CharSequence b) | Remplace toutes les occurrences d’une sous-chaîne. Répète une recherche de sous-chaîne. | \(O(n\,m)\) au pire |
toUpperCase(), toLowerCase() | Changement de casse, selon les règles de la langue courante (Locale). | \(O(n)\) |
trim() | Retire les caractères de code inférieur ou égal à l’espace au début et à la fin. | \(O(n)\) |
strip(), stripLeading(), stripTrailing() | Comme trim(), mais en reconnaissant tous les espaces Unicode. À privilégier (Java 11+). | \(O(n)\) |
repeat(int k) | Répète la chaîne k fois. | \(O(n\,k)\) |
intern() | Retourne l’exemplaire canonique de la chaîne, conservé dans une table interne de la JVM. À utiliser avec parcimonie. | \(O(n)\) |
Découper et assembler #
| Méthode | Description | Complexité |
|---|---|---|
split(String regex) | Découpe la chaîne selon une expression régulière et retourne un tableau. Java optimise le cas d’un séparateur d’un seul caractère ordinaire. | \(O(n)\) en pratique |
lines() | Produit un flux des lignes de la chaîne. | \(O(n)\) au parcours |
String.join(sep, parties) | Assemble des chaînes en les séparant par sep. Une seule allocation. | \(O(L)\), où \(L\) est la longueur totale |
String.format(modele, ...) | Construit une chaîne formatée (%s, %d, %.2f, etc.). | \(O(L)\) |
String.valueOf(x) | Convertit une valeur quelconque en chaîne. | dépend de x |
Dans ce cours, il n’est pas nécessaire de mémoriser ces complexités. Retenez surtout deux choses : l’accès à un caractère (
charAt) est immédiat, alors que presque toute autre opération parcourt la chaîne au complet. C’est pourquoi il faut éviter de reconstruire des chaînes à répétition à l’intérieur d’une boucle.
La recherche de sous-chaîne : indexOf
#
La méthode indexOf(String) répond à une question simple : à quelle position le motif
apparaît-il dans le texte ? L’approche la plus directe consiste à essayer chaque position
possible, l’une après l’autre :
// Recherche naïve : à ne pas utiliser en pratique, mais facile à comprendre.
static int rechercheNaive(String texte, String motif) {
int n = texte.length(), m = motif.length();
for (int position = 0; position + m <= n; position++) {
int j = 0;
while (j < m && texte.charAt(position + j) == motif.charAt(j)) {
j++;
}
if (j == m) return position; // le motif entier a concordé
}
return -1;
}
Sur du texte ordinaire, cette boucle s’arrête presque toujours dès la première lettre :
le coût est alors d’environ \(n\) comparaisons. Mais si le texte et le motif se
ressemblent beaucoup, chaque essai peut aller très loin avant d’échouer. Avec un texte
formé d’un million de a et un motif formé de 4096 a suivis d’un seul b, chacun des
\(n\) essais compare 4096 caractères avant d’échouer : on obtient \(n\,m\) comparaisons,
soit un comportement quadratique.
On pourrait croire que la bibliothèque standard de Java échappe à ce problème. Ce n’est
pas le cas : la mise en œuvre de String.indexOf est très optimisée. Mais elle a tout
de même une complexité de \(n\,m\) comparaisons dans le pire des cas. Voir à ce sujet
Java’s String.indexOf can be slow (quadratic).
Conséquence pratique : si votre programme cherche dans un texte un motif fourni par l’utilisateur, un motif très long peut suffire à le paralyser. La parade la plus simple est de refuser les motifs démesurément longs.
L’algorithme de Horspool #
Peut-on faire mieux que d’essayer chaque position ? Oui, et l’idée est étonnamment simple.
Elle est due à Nigel Horspool (1980), qui a proposé une version simplifiée de l’algorithme
de Boyer-Moore. Les mises en œuvre efficaces de la recherche de sous-chaîne — dans les
bibliothèques standard de plusieurs langages, dans les éditeurs de texte, dans les outils
comme grep — reposent souvent sur cet algorithme ou sur l’un de ses proches parents.
L’algorithme de Horspool tient en deux observations :
- On compare le motif de la droite vers la gauche, alors qu’on le fait glisser vers la droite.
- Lorsqu’une comparaison échoue, on regarde le caractère du texte qui était aligné avec la dernière lettre du motif. Ce caractère nous dit de combien on peut glisser sans risquer de rater une occurrence : si ce caractère n’apparaît nulle part dans le motif, on peut glisser de toute la longueur du motif d’un seul coup.
On prépare donc, avant la recherche, une petite table de décalage : pour chaque
caractère c, decalage[c] vaut la distance entre la dernière occurrence de c dans le
motif (sa dernière lettre exclue) et la fin du motif ; et vaut la longueur du motif si c
n’y figure pas.
Voyons l’algorithme à l’œuvre sur un petit exemple.
Trois alignements suffisent là où la recherche naïve en aurait essayé cinq. Surtout, l’algorithme n’a jamais lu les caractères des positions 0 et 1 du texte : il les a survolés. Sur un exemple aussi court le gain est modeste, mais il devient spectaculaire quand le motif s’allonge, comme nous le verrons.
Voici une mise en œuvre complète en Java. Le programme compte les comparaisons afin de rendre le travail effectué visible.
Un algorithme qui lit tout le texte fait au moins \(n\) opérations. Horspool, lui, peut en faire moins : c’est ce qu’on appelle un comportement sous-linéaire. La raison est qu’il ne lit pas tous les caractères du texte ; il en saute.
Prenons le cas le plus net. Le motif est xyz (trois lettres) et le texte ne contient que
des a. À chaque essai :
- on compare la dernière lettre du motif,
z, avec un caractère du texte,a: désaccord dès la première comparaison ; - le caractère
an’apparaît pas dans le motif, doncdecalage['a']vaut 3 : on glisse de trois positions d’un seul coup.
Chaque comparaison fait donc avancer de trois caractères. Pour un texte de \(n\) caractères, il n’en faut que \(n/3\). Plus généralement, avec un motif de longueur \(m\) dont les lettres sont rares dans le texte, le coût descend vers \(n/m\) comparaisons : plus le motif est long, plus la recherche est rapide. C’est exactement l’inverse de la recherche naïve.
Il existe des algorithmes dont le pire cas est garanti linéaire, c’est-à-dire \(O(n+m)\) : Knuth-Morris-Pratt (1977) et l’algorithme Two-Way de Crochemore et Perrin (1991), ce dernier étant utilisé par plusieurs bibliothèques C. Ils ne sont toutefois pas toujours plus rapides en pratique : sur du texte ordinaire, leur surcoût les rend souvent plus lents qu’une recherche simple. Il n’existe pas de meilleur algorithme dans l’absolu, seulement des compromis.
Les expressions régulières #
Une expression régulière (ou regex) est un petit langage qui sert à décrire une
forme de texte plutôt qu’un texte précis. Au lieu de chercher le mot exact « 2026-08-26 »,
on décrit « quatre chiffres, un tiret, deux chiffres, un tiret, deux chiffres ». Plusieurs
méthodes de String acceptent une expression régulière en argument.
Voici les éléments de syntaxe les plus courants. Attention : en Java, l’expression
régulière est écrite dans une chaîne de caractères, et la barre oblique inverse doit donc
être doublée. Pour exprimer « un chiffre », on écrit \d en notation regex, ce qui donne
"\\d" dans le code Java.
| Notation | Signification | Exemple |
|---|---|---|
. | n’importe quel caractère | "a.c" reconnaît abc, axc |
\d | un chiffre | "\\d\\d" reconnaît 42 |
\w | une lettre, un chiffre ou le trait de soulignement | "\\w+" reconnaît nom_1 |
\s | une espace, une tabulation, un saut de ligne | "\\s+" reconnaît une suite d’espaces |
[abc] | l’un des caractères énumérés | "[aeiou]" reconnaît une voyelle |
[a-z] | un caractère de l’intervalle | "[A-Z]" reconnaît une majuscule |
[^abc] | tout sauf les caractères énumérés | "[^0-9]" reconnaît un non-chiffre |
? | l’élément précédent, zéro ou une fois | "colou?r" reconnaît color et colour |
* | l’élément précédent, zéro fois ou plus | "ab*" reconnaît a, ab, abb |
+ | l’élément précédent, une fois ou plus | "\\d+" reconnaît 7, 2026 |
{n}, {n,m} | un nombre précis de répétitions | "\\d{4}" reconnaît 2026 |
(...) | un groupe, que l’on peut récupérer séparément | "(\\d{4})-(\\d{2})" |
a|b | l’un ou l’autre | "chat|chien" |
^, $ | début, fin de la chaîne | "^Bonjour" |
Les méthodes concernées de la classe String sont les suivantes.
| Méthode | Description | Complexité |
|---|---|---|
matches(String regex) | Vrai si la chaîne entière correspond au motif. Attention : ce n’est pas une recherche partielle. | \(O(n)\) en général, exponentielle au pire |
replaceAll(String regex, String remplacement) | Remplace toutes les portions qui correspondent au motif. | idem |
replaceFirst(String regex, String remplacement) | Ne remplace que la première portion. | idem |
split(String regex) | Découpe la chaîne aux endroits qui correspondent au motif. | idem |
Ces quatre méthodes recompilent l’expression régulière à chaque appel. Dans une boucle, il vaut donc mieux compiler le
motif une seule fois avec Pattern.compile et le réutiliser. La classe Matcher permet en
outre de récupérer les portions reconnues, ce que String ne sait pas faire.
Le moteur d’expressions régulières de Java procède par retour sur trace (backtracking) : quand une piste échoue, il revient en arrière et en essaie une autre. Sur certains motifs, le nombre de pistes explose et le temps de calcul devient exponentiel. L’exemple classique est le motif
(a+)+bconfronté à une chaîne formée uniquement dea:// À NE PAS EXÉCUTER tel quel : le temps de calcul double à chaque « a » ajouté. "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaa".matches("(a+)+b");Comme pour
indexOf, la leçon est la même : méfiez-vous des motifs et des textes fournis par l’utilisateur. Ce défaut porte un nom, ReDoS (déni de service par expression régulière).
StringBuilder #
Le type StringBuilder en Java permet de construire et de modifier efficacement des chaînes de caractères. Contrairement à la classe String, qui est immuable (chaque modification crée un nouvel objet), StringBuilder permet d’ajouter, de modifier ou de supprimer des caractères sans créer de nouveaux objets à chaque opération. Cela le rend particulièrement utile lorsqu’on doit faire de nombreuses modifications ou concaténations de chaînes, par exemple lors de la lecture d’un fichier ou la construction dynamique d’un texte.
L’utilisation de StringBuilder améliore considérablement les performances, surtout dans les boucles : concaténer des chaînes avec + dans une boucle crée à chaque fois une nouvelle chaîne, ce qui consomme beaucoup de mémoire et ralentit le programme. StringBuilder évite ce problème en travaillant sur une seule zone mémoire.
Exemple :
StringBuilder sb = new StringBuilder();
for (int i = 0; i < 5; i++) {
sb.append("Ligne ").append(i).append("\n");
}
String resultat = sb.toString();
System.out.println(resultat);
Dans cet exemple, toutes les lignes sont ajoutées efficacement à la même chaîne. Pour des opérations répétées ou sur de gros volumes de texte, StringBuilder est donc le choix recommandé pour de bonnes performances.
Voici un exemple en Java qui illustre la plupart des propriétés et méthodes de la classe StringBuilder.
CharSequence et subSequence() #
L’interface CharSequence représente une séquence de caractères lisible : elle est implémentée par plusieurs classes Java comme String, StringBuilder et StringBuffer. Cela permet d’écrire des méthodes qui acceptent n’importe quel type de séquence de caractères, et pas seulement des chaînes immuables.
La méthode subSequence(int start, int end) permet d’obtenir une portion (sous-séquence) de la séquence de caractères, allant de l’indice start (inclus) à end (exclu). C’est utile pour extraire une partie d’un texte sans créer une nouvelle chaîne si ce n’est pas nécessaire.
Exemple avec String :
String texte = "Bonjour le monde";
CharSequence sousTexte = texte.subSequence(8, 14); // "le mon"
System.out.println(sousTexte);
Exemple avec StringBuilder :
StringBuilder sb = new StringBuilder("abcdefg");
CharSequence sousSeq = sb.subSequence(2, 5); // "cde"
System.out.println(sousSeq);
Utiliser CharSequence rend le code plus flexible : on peut manipuler des chaînes, des buffers ou des builders de la même façon, et extraire facilement des sous-parties avec subSequence(). La méthode subSequence évite de faire une copie inutile.
Le code de hachage : hashCode()
#
Toute classe Java hérite d’une méthode hashCode() qui retourne un int, appelé le code de hachage de l’objet. Ce nombre sert à ranger les objets dans les structures de données comme HashMap et HashSet, qui l’utilisent pour décider dans quel « seau » déposer une clé. La classe String redéfinit cette méthode pour que le code dépende du contenu de la chaîne, et non de l’adresse mémoire de l’objet.
Le contrat entre equals et hashCode
#
La règle fondamentale est une implication à sens unique :
- Si deux chaînes sont égales au sens de
equals, alors elles ont nécessairement le même code de hachage. C’est une garantie absolue : deux chaînes qui contiennent exactement les mêmes caractères produisent toujours le mêmeint, quelle que soit la façon dont elles ont été construites, et quelle que soit la machine virtuelle Java utilisée. - La réciproque est fausse. Deux chaînes différentes peuvent parfaitement avoir le même code de hachage. On parle alors d’une collision.
String a = "Bonjour";
String b = "Bon" + "jour";
System.out.println(a.equals(b)); // true
System.out.println(a.hashCode() == b.hashCode()); // true, garanti
String x = "Aa";
String y = "BB";
System.out.println(x.hashCode() == y.hashCode()); // true
System.out.println(x.equals(y)); // false : une collision
Les collisions ne sont pas un défaut d’implantation : elles sont mathématiquement inévitables. Un int ne peut prendre que \( 2^{32} \), soit environ 4,3 milliards de valeurs différentes, alors qu’il existe une infinité de chaînes de caractères. Rien qu’avec les mots de sept lettres minuscules, on compte déjà \( 26^7 \approx 8 \) milliards de possibilités, soit près du double du nombre de codes disponibles. Par le principe des tiroirs, certaines de ces chaînes partagent forcément un code.
Il faut donc retenir la façon correcte d’utiliser un code de hachage : il sert à écarter rapidement, jamais à conclure. Si deux codes diffèrent, les objets sont certainement différents et on s’arrête là. Si les codes sont identiques, on ne sait rien encore et il faut appeler equals pour trancher. C’est exactement ce que fait HashMap en interne. Un code de hachage n’est donc ni un identifiant unique, ni une empreinte de sécurité : pour vérifier l’intégrité d’un document, on utilise plutôt une fonction de hachage cryptographique comme SHA-256, offerte en Java par la classe MessageDigest.
La mise en œuvre dans String
#
Contrairement à la plupart des méthodes de la bibliothèque standard, le code de hachage d’une chaîne n’est pas laissé au choix de l’implantation : sa valeur est fixée par la documentation officielle de Java. Pour une chaîne \( s \) de \( n \) caractères, il vaut
\[ s[0] \times 31^{n-1} + s[1] \times 31^{n-2} + \dots + s[n-2] \times 31 + s[n-1] \]Le code de la chaîne vide vaut 0. En pratique, on n’évalue pas les puissances de 31 : on utilise la méthode de Horner, qui ramène le calcul à une simple boucle avec une multiplication et une addition par caractère.
public int monHashCode(String s) {
int h = 0;
for (int i = 0; i < s.length(); i++) {
h = 31 * h + s.charAt(i);
}
return h;
}
Quelques remarques sur cette implantation :
- Le calcul se fait sur des
intde 32 bits et déborde très vite. En Java, ce débordement n’est pas une erreur : l’arithmétique sur lesintboucle silencieusement, ce qui revient à travailler modulo \( 2^{32} \). C’est voulu. - Le multiplicateur 31 est un nombre premier impair, ce qui aide à répartir les valeurs. Il a aussi l’avantage d’être bon marché :
31 * hs’écrit(h << 5) - h, soit un décalage et une soustraction, une optimisation que le compilateur applique automatiquement. - Comme le résultat est imposé par la spécification, il est identique sur toutes les machines virtuelles Java et il ne changera jamais. Cette stabilité est pratique, mais nous verrons qu’elle a un prix.
- Puisqu’une chaîne est immuable, son code de hachage ne peut pas changer une fois calculé. La classe
Stringen profite pour le mémoriser dans un champ privé lors du premier appel, de sorte que les appels suivants sont gratuits. C’est un exemple concret d’un avantage de l’immuabilité.
Si vous écrivez vos propres classes, souvenez-vous que redéfinir equals sans redéfinir hashCode brise le contrat et rend vos objets inutilisables comme clés dans une HashMap. La méthode utilitaire java.util.Objects.hash(...) permet de combiner facilement les champs d’un objet.
Fabriquer des collisions à volonté #
Les collisions sont inévitables, mais dans une fonction de hachage bien conçue elles restent rares et difficiles à provoquer. Ce n’est pas le cas ici : la formule de String.hashCode() est publique, simple et parfaitement prévisible, ce qui permet de construire des collisions à la main.
Partons de deux caractères. Dans un mot de deux lettres, le premier caractère est multiplié par 31 et le second ne l’est pas. Si on augmente le premier caractère de 1, le code augmente de 31 ; si on diminue le second de 31, le code diminue d’autant. Les deux effets s’annulent. En passant de "Aa" à "BB", on avance le A (valeur 65) d’un cran vers B (valeur 66) et on recule le a (valeur 97) de 31 crans jusqu’à B (valeur 66) :
"Aa"donne \( 65 \times 31 + 97 = 2112 \)"BB"donne \( 66 \times 31 + 66 = 2112 \)
À ce stade, nous n’avons qu’une seule paire, ce qui n’est pas bien menaçant. Mais la formule possède une propriété qui rend l’attaque redoutable. Si on colle une chaîne \( v \) à la suite d’une chaîne \( u \), le code du résultat vaut
\[ h(u \cdot v) = h(u) \times 31^{|v|} + h(v) \]Autrement dit, la contribution de chaque bloc ne dépend que de son propre code de hachage et de sa position. Par conséquent, si deux blocs de même longueur ont le même code, on peut les échanger n’importe où dans une chaîne sans changer le résultat. Comme "Aa" et "BB" font tous deux deux caractères et partagent le code 2112, toutes les chaînes formées en enchaînant \( k \) blocs choisis librement parmi ces deux-là ont rigoureusement le même code de hachage :
"AaAaAa", "AaAaBB", "AaBBAa", "AaBBBB", "BBAaAa", "BBAaBB", "BBBBAa", "BBBBBB"
Ces huit chaînes de six caractères ont toutes le code 1952508096. Et le procédé se généralise sans effort : avec \( k \) blocs, on obtient \( 2^k \) chaînes distinctes qui se partagent un seul et unique code. Une vingtaine de blocs suffisent donc à produire plus d’un million de clés en collision, et il n’en coûte que quelques lignes de code.
Pourquoi cela met les tables de hachage en danger #
Une table de hachage range chaque clé dans un seau déterminé par son code de hachage. Tant que les clés se répartissent uniformément, chaque seau ne contient qu’une poignée d’éléments et les opérations d’insertion et de recherche coûtent un temps constant.
Avec des clés fabriquées comme ci-dessus, tout s’effondre : elles atterrissent toutes dans le même seau. La table dégénère alors en une simple liste. Pour insérer la \( i \)-ième clé, il faut la comparer aux \( i-1 \) clés déjà présentes afin de vérifier qu’elle n’y est pas déjà. Insérer \( n \) clés coûte donc de l’ordre de \( n^2/2 \) comparaisons au lieu de \( n \).
Ce n’est pas qu’une curiosité théorique. En 2011, des chercheurs ont montré à la conférence 28C3 que la plupart des plateformes web de l’époque, en Java comme en PHP, en Python ou en Ruby, étaient vulnérables à cette faiblesse. Un serveur qui range les paramètres d’un formulaire dans une table de hachage peut être paralysé par une seule requête contenant quelques milliers de noms de paramètres soigneusement choisis. Le coût est dérisoire pour l’attaquant et énorme pour le serveur : c’est une attaque par déni de service.
Le programme suivant construit \( 2^{14} = 16\,384 \) chaînes qui partagent toutes le même code de hachage, puis mesure le temps d’insertion dans une table de hachage naïve et dans une HashMap de Java.
Sur une machine récente, la table naïve met environ 2,5 ms pour les clés ordinaires, mais 143 ms pour les clés en collision, soit près de 60 fois plus. Et l’écart empire avec la taille : en quadruplant le nombre de clés, on multiplie la pénalité par environ cinq, ce qui est bien la signature d’un comportement quadratique.
Les parades #
La HashMap de Java se défend nettement mieux : dans la même expérience, elle ne prend que 13,6 ms au lieu de 3,8 ms, un facteur d’environ 3,5 seulement. Depuis Java 8, lorsqu’un seau accumule au moins huit éléments dans une table d’au moins 64 seaux, la liste chaînée est convertie en un arbre rouge-noir. Comme les chaînes sont comparables entre elles avec compareTo, la recherche dans le seau passe de \( O(n) \) à \( O(\log n) \). Voilà une application directe des arbres équilibrés étudiés dans le premier module.
Cette parade limite les dégâts sans supprimer la cause. D’autres langages ont choisi une approche différente : ils tirent au hasard, au démarrage du programme, une clé secrète qui entre dans le calcul du hachage, si bien qu’un attaquant ne peut plus prédire les collisions. Java ne peut pas se le permettre pour String, justement parce que la valeur retournée par hashCode() fait partie de la spécification publique du langage : la changer casserait tous les programmes qui la sauvegardent ou la transmettent.
Il reste donc quelques précautions à prendre lorsqu’on manipule des données venant de l’extérieur :
- Limiter le nombre de clés qu’une source non fiable peut insérer dans une table de hachage.
- Se méfier de toute structure indexée par des chaînes que l’utilisateur contrôle entièrement.
- Ne jamais confondre
hashCode()avec une empreinte de sécurité : pour signer ou vérifier des données, il faut une fonction de hachage cryptographique.
Allocation de mémoire et ramasse-miettes #
Comprendre l’allocation de mémoire et le ramasse-miettes n’est pas obligatoire dans ce cours.
Lorsque vous créez un objet en Java, la mémoire nécessaire est automatiquement allouée dans une zone appelée le « tas » (heap). Contrairement à certains langages comme C ou C++, il n’est pas nécessaire de libérer explicitement la mémoire des objets qui ne sont plus utilisés. Java intègre un mécanisme appelé ramasse-miettes (ou garbage collector) qui se charge de détecter et de libérer automatiquement la mémoire occupée par les objets devenus inaccessibles. Il partage cette caractéristique avec d’autres langages comme C#, JavaScript et Python.
Le ramasse-miettes fonctionne en arrière-plan : il identifie les objets qui ne sont plus référencés par aucune variable ou structure de données, puis récupère la mémoire correspondante pour la rendre disponible à de nouveaux objets. Cela simplifie la gestion de la mémoire et réduit les risques de fuites de mémoire (memory leaks) ou d’erreurs de libération (comme les double free en C).
Cependant, il est important de comprendre que la libération de la mémoire n’est pas instantanée : le ramasse-miettes intervient à des moments choisis par la machine virtuelle Java (JVM), ce qui peut parfois entraîner de légères pauses dans l’exécution du programme. Pour la plupart des applications, ce fonctionnement automatique est un avantage, car il permet de se concentrer sur la logique du programme sans se soucier de la gestion manuelle de la mémoire.
L’allocation de mémoire en Java est automatique et la libération est assurée par le ramasse-miettes, ce qui contribue à la robustesse et à la sécurité des programmes Java.
Par contre, le ramasse-miettes a des inconvénients : il peut provoquer des pauses imprévisibles dans l’exécution du programme, appelées « pauses de collecte », lorsque la JVM décide de libérer la mémoire. Ces pauses sont généralement courtes, mais peuvent devenir perceptibles dans des applications nécessitant une grande réactivité (jeux, systèmes temps réel, etc.). De plus, le développeur a moins de contrôle sur le moment précis où la mémoire est libérée, ce qui peut compliquer l’optimisation des performances dans certains cas particuliers. Enfin, le ramasse-miettes consomme lui-même des ressources processeur, ce qui peut avoir un effet sur l’efficacité globale du programme.
Malgré l’existence du ramasse-miettes, il faut donc tenter de minimiser l’allocation de mémoire. Il faut éviter de créer des objets temporaires quand on peut réutiliser un objet déjà alloué.
Considérons l’exemple suivant.
- Approche 1 (String) : À chaque itération, l’opérateur += crée un nouvel objet String, car les objets String sont immuables en Java. Cela génère de nombreux objets temporaires qui doivent être gérés par le ramasse-miettes, augmentant la charge mémoire et le temps d’exécution.
- Approche 2 (StringBuilder) : En utilisant StringBuilder, un seul objet est créé et modifié à chaque itération. Cela réduit considérablement le nombre d’allocations mémoire et la charge sur le ramasse-miettes, ce qui améliore les performances.