1. Introduction
Si vous ecrivez un projet d’entrainement et qu’il traite 10 lignes, les questions de memoire ressemblent a du pinaillage du genre « ne respire pas pres du processeur — il va surchauffer ». Mais des que les donnees grossissent (logs, fichiers, enregistrements dans des collections, rapports), on decouvre soudain que les problemes les plus couteux ne sont pas les « algorithmes complexes », mais une multitude de petites allocations et un nettoyage permanent des dechets par le ramasse-miettes. Sur la JVM, cela se manifeste par des pauses imprevisibles, des pics de consommation memoire et l’impression que le programme tantot vole, tantot fixe pensivement la fenetre.
Pour parler le meme langage que la JVM, il nous faut un modele operationnel en trois mots : stack, heap, GC. Ce modele n’explique pas absolument tout (aujourd’hui, nous evitons volontairement le bytecode/JIT/les reglages fins du GC), mais il explique tres bien 80 % des « pourquoi c’est devenu plus lent » et « ou est passee la memoire ».
2. Modele minimal de memoire JVM : stack et heap
Quand vous appelez une fonction en Kotlin, vous ne faites pas juste « passer a une autre ligne ». La JVM doit stocker quelque part des informations sur l’appel : de quel appel il s’agit, quelles variables locales il contient, ou revenir ensuite. Cet endroit, c’est la stack (pile). Et quand vous creez des objets (classes, chaines, listes, exceptions), ils vivent en general dans le heap (tas). Et quand vous cessez de les referencer, le GC (garbage collector) arrive periodiquement et dit : « Bon, qui est en trop ici ? ».
Une idee importante tout de suite : la plupart des problemes de performance dans le « Kotlin applicatif classique » ne viennent pas de la pile, mais du tas. La pile casse en general de maniere dramatique (par exemple, StackOverflowError), tandis que le tas casse lentement et tristement : « quelque chose consomme de plus en plus de memoire et il y a des pauses bizarres ».
Stack : memoire pour les appels de fonctions et pourquoi elle n’est pas « extensible a l’infini »
Imaginez une pile de plateaux a la cantine. Vous posez un nouveau plateau au-dessus quand vous appelez une fonction, et vous l’enlevez quand la fonction se termine. Pendant l’execution, sur son « plateau » se trouvent (grossierement) les variables locales. C’est ca, la pile : une structure LIFO (dernier arrive — premier sorti).
Pour nous, le sens pratique est le suivant : si vous faites par accident une recursion infinie ou une recursion tres profonde, la pile grandit, grandit, grandit… et finit par se remplir. Sur la JVM, cela conduit en general a StackOverflowError (en Kotlin, cela reste une erreur serieuse de niveau Error, et non pas une « erreur ordinaire qu’il faut attraper »). Dans la documentation Kotlin sur les exceptions, il est souligne explicitement que ce genre de choses releve de problemes dont l’application ne peut souvent pas « se remettre normalement ».
Nous n’avons pas besoin d’optimiser la pile maintenant. Il est important de comprendre : la pile concerne la profondeur d’appels et les donnees locales d’un appel, pas « toutes vos listes et vos chaines ».
Heap : memoire pour les objets, tout ce qui est interessant et tout ce qui coute cher
Le heap est un « entrepot commun d’objets ». Il recoit presque tout ce qui, en Kotlin, ressemble a un objet : instances de classes, String, collections, Pair, lambdas (dans certains cas), exceptions, etc.
Point cle : le heap « vit plus longtemps qu’un seul appel de fonction ». Vous creez une MutableList, vous la retournez depuis une fonction — elle continue d’exister. Tant qu’il y a des references, elle est consideree comme necessaire. Des qu’il n’y a plus de references, l’objet devient candidat au ramassage.
Et ici apparait une idee subtile mais tres pratique : la performance est fortement influencee non seulement par « combien d’objets vivent », mais aussi par « a quelle frequence vous en creez de nouveaux ». Si vous creez chaque seconde un million de petits objets, le GC sera tres occupe.
References : pourquoi val ne « met pas l’objet dans la variable »
Beaucoup de debutants pensent inconsciemment que val x = ... « met l’objet dans une variable ». Sur la JVM, il est souvent plus utile de penser ainsi : la variable stocke une reference vers un objet dans le heap (adresse/pointeur au sens courant), et non pas l’objet lui-meme.
Donc la phrase « une collection dans val est immuable » est fausse (si c’est une MutableList). val protege la reference (on ne peut pas reassocier une autre collection), mais n’interdit pas de modifier l’objet lui-meme. La documentation Kotlin precise a part que l’on peut meme garder une collection mutable dans un val pour ne pas perdre le controle de la reference et ne pas la remplacer accidentellement par une autre collection.
Mini-exemple pour « sentir » l’idee des references :
fun main() {
val xs = mutableListOf(1, 2)
val ys = xs
ys.add(3)
println(xs) // [1, 2, 3]
println(ys) // [1, 2, 3]
}
ys n’a pas « copie la liste », mais a obtenu une seconde reference vers le meme objet dans le heap.
3. GC : qui sort les poubelles et pourquoi il gene parfois
Le ramasse-miettes (GC) dans la JVM est un mecanisme qui libere la memoire dans le heap quand les objets deviennent inaccessibles (plus personne ne les reference). Il fait ce qu’il faudrait faire manuellement dans des langages sans GC (et oui, manuellement, cela se termine soit par des fuites, soit par « oups, on a libere deux fois », soit par tout a la fois).
Mais en echange du confort, le nettoyage est un travail qui consomme des ressources et provoque parfois des pauses. Dans le meilleur des cas, vous ne remarquez pas le GC. Dans le pire, le programme commence a « micro-freezer », et vous ouvrez le monitoring et voyez : le CPU ne fait pas de travail utile, mais l’application fait des pauses de temps en temps.
Un modele du quotidien utile : le GC est d’autant plus heureux que vous produisez moins de dechets. Et on arrive doucement aux allocations.
Dessinons un schema simplifie du « cycle de vie d’un objet » :
flowchart TD
A[Creation d’un objet dans le heap] --> B[Il existe des references vers lui]
B --> C{Les references ont disparu ?}
C -- non --> B
C -- oui --> D[L’objet devient candidat au GC]
D --> E[Le GC libere la memoire]
Cela suffit pour comprendre ensuite pourquoi les « objets temporaires en trop » ne sont pas une broutille inoffensive.
4. Allocations : ou Kotlin fait naitre des objets superflus
Une allocation est le moment ou la JVM reserve de la place dans le heap pour un nouvel objet. En soi, une allocation n’est generalement pas une catastrophe. La catastrophe, c’est mille allocations dans une boucle qui tourne un million de fois. Notre objectif est donc d’apprendre a reperer les endroits typiques ou du code Kotlin cree « par accident » beaucoup d’objets temporaires.
La maniere la plus honnete de raisonner : tout ce qui ressemble a « creer une nouvelle valeur » peut potentiellement creer un nouvel objet. Parfois le compilateur optimise, parfois non. On garde donc en tete des patterns simples qui coutent presque toujours cher.
Chaines et concatenation dans des boucles : la fabrique a dechets classique
Les chaines dans la JVM sont des objets. De plus, elles sont immuables (immutable) : si vous faites "a" + "b", la JVM n’a pas « complete l’ancienne chaine », elle en a cree une nouvelle.
En Kotlin, l’operateur + pour les chaines est pratique, mais si vous commencez a faire result += ... dans une boucle, vous creez souvent beaucoup de chaines temporaires. Anti-exemple typique :
fun buildReportBad(lines: List<String>): String {
var s = ""
for (line in lines) {
s += line + "\n"
}
return s
}
Oui, c’est lisible. Oui, ca marche. Oui, le GC pleure ensuite.
L’outil pratique adequat, c’est StringBuilder :
fun buildReportGood(lines: List<String>): String {
val sb = StringBuilder()
for (line in lines) {
sb.append(line).append('\n')
}
return sb.toString()
}
StringBuilder conserve un tampon interne et l’agrandit au besoin, donc vous ne creez pas une chaine a chaque etape.
Collections et chaines map/filter : les listes intermediaires sont aussi des allocations
Beaucoup d’operations sur les collections en Kotlin renvoient une nouvelle collection : map, filter, sorted, etc. C’est excellent pour la lisibilite et le style fonctionnel, mais il faut se souvenir : chaque operation peut creer une liste intermediaire.
Si vous avez une chaine de cinq transformations sur une grosse collection, vous creez potentiellement plusieurs grosses listes temporaires. Parfois c’est acceptable (la lisibilite prime). Parfois c’est cher. C’est la qu’on se rappelle de Sequence : elle permet de rendre les transformations paresseuses et de reduire le nombre de collections intermediaires (mais ce n’est pas gratuit non plus).
Au passage, la documentation Kotlin souligne la difference entre collections et tableaux : un tableau a une taille fixe, les collections sont plus pratiques pour ajouter/supprimer des elements et, globalement, pour les taches courantes. Ce n’est pas directement « a propos de la memoire », mais c’est important indirectement : tenter « d’optimiser tout avec des tableaux » se transforme souvent en code complexe et fragile.
Pair, Triple et petits objets temporaires : ca parait minime, mais ca fait deja un seau
Pair et Triple sont pratiques, mais ce sont aussi des objets. Si vous les utilisez comme conteneurs temporaires dans un point chaud (par exemple, en renvoyant un Pair depuis une fonction dans une boucle), vous creez beaucoup d’objets a courte duree de vie.
Parfois c’est acceptable. Parfois, il vaut mieux renvoyer les valeurs autrement (par exemple via un modele specialise, via la mise a jour d’un accumulateur, ou via fold). Mais il est important de ne pas transformer le code en « optimisation pour l’optimisation » : d’abord, vous devez comprendre quel est votre point chaud.
5. Boxing : pourquoi List<Int> peut etre plus lourd qu’il n’y parait
Le boxing (encapsulation) est la situation ou un « simple nombre » (Int) devient un objet enveloppe, parce qu’il doit etre place la ou des objets sont attendus (par exemple, dans une collection generique).
Sur Kotlin/JVM, les generics fonctionnent principalement avec des types de reference, et donc List<Int> au niveau JVM stocke souvent non pas des « int nus », mais des objets enveloppes. Cela ne veut pas dire que List<Int> est toujours mauvais. Cela veut dire que, pour d’enormes tableaux de nombres, il est parfois plus avantageux d’utiliser des structures specialisees.
La documentation Kotlin sur les tableaux indique clairement que si l’on utilise Array avec des primitifs, ils seront boxed, et propose a la place des tableaux de primitifs (IntArray, DoubleArray, etc.) pour eviter le surcout du boxing.
Array<Int> vs IntArray : un petit exemple « au doigt mouille »
fun main() {
val a: Array<Int> = arrayOf(1, 2, 3)
val b: IntArray = intArrayOf(1, 2, 3)
println(a.joinToString()) // 1, 2, 3
println(b.joinToString()) // 1, 2, 3
}
Vu de l’exterieur, c’est presque pareil. Mais l’idee est la suivante : IntArray stocke les primitifs plus densement et sans encapsulation.
Quand est-ce important dans notre « projet CLI pratique »
Dans notre application d’apprentissage (un CLI de type tracker/analyseur, ou l’on stocke des enregistrements et ou l’on genere des rapports), le plus souvent les donnees sont des objets : enregistrements de depenses/evenements/chaines. Ici, le boxing n’est pas l’ennemi principal.
Mais si une sous-partie apparait et stocke de grandes series numeriques (par exemple, des sommes journalieres par jour, des metriques, des series temporelles), alors passer de List<Int> a IntArray peut parfois reduire fortement la pression memoire. Et cela impacte le GC : moins d’objets — moins de dechets — moins de pauses.
6. Surcouts : exceptions et reflection
Les jours precedents du cours, nous avons deja discute des exceptions comme mecanisme de gestion d’erreurs. Ici, nous les regardons sous l’angle de la memoire et des performances — et nous mettons la reflection a cote, parce qu’elle a un profil similaire : « puissant, mais pas gratuit ».
Exceptions et stack trace : pourquoi try/catch pour le parsing coute cher
Une exception est un objet. Et elle embarque du contexte : un message, une cause (cause), et la partie la plus « couteuse » — la stack trace, c’est-a-dire la liste des frames de pile indiquant comment l’execution est arrivee au point d’erreur. La documentation Kotlin explique a part que la stack trace est une sequence d’appels de fonctions et donne un exemple de sortie sur la JVM.
C’est utile pour le debogage, mais cela signifie : creer une exception n’est pas equivalent a « renvoyer null ». Une exception est en general plus chere, car il faut collecter des informations diagnostiques.
De plus, la documentation souligne que les exceptions sont des objets avec etat (stateful), et qu’il ne faut pas en faire des singletons object, parce que l’etat (y compris la stack trace) doit refleter le lieu d’apparition. C’est un autre indice : une exception n’est pas un « petit drapeau bon marche », mais un « gros dossier de documents ».
Si l’erreur est attendue (par exemple, l’utilisateur a saisi autre chose qu’un nombre), il vaut souvent mieux utiliser toIntOrNull()/toDoubleOrNull() et de la validation. Dans notre cours, cela a deja ete vu : « lire → preparer → parser », et en cas de probleme — renvoyer null et demander de ressaisir.
Et ici, il est pratique de se rappeler du style fail-fast : require et check permettent de lever rapidement une exception si l’on ne peut pas continuer, et Kotlin decrit officiellement ces preconditions comme une facon de lancer automatiquement des exceptions quand les conditions sont violees.
Reflection et memoire : pourquoi l’acces dynamique est lourd
Nous venons d’etudier la reflection, donc on peut le dire honnetement : oui, c’est puissant, mais rien n’est gratuit.
La reflection conduit souvent a :
- des metadonnees supplementaires (parfois aussi une dependance separee),
- la recherche de membres par parcours de listes,
- la creation d’objets auxiliaires pour l’appel,
- une trace et un debogage plus complexes.
Du point de vue memoire, le conseil pratique principal est le suivant : ne faites pas de reflection dans une boucle chaude. Si vous devez appeler une methode/lire une propriete par son nom, il vaut generalement mieux trouver une fois la « description » (un KFunction/KProperty conditionnel) et la reutiliser, plutot que de rescanner memberFunctions a chaque fois.
Meme si vous ne mesurez pas cela avec des benchmarks (aujourd’hui, nous n’entrons pas dans la discipline des mesures), le bon sens fonctionne tres bien ici : repeter « recherche dans une liste » + « wrappers » + « appel via call » 100000 fois est presque toujours une mauvaise idee.
7. Mini-refactoring : reduire les allocations sans perdre en lisibilite
Nous allons faire la chose la plus utile au quotidien : regarder un morceau de code du « projet CLI pratique » et l’ameliorer pour qu’il produise moins de dechets, tout en restant clair. Important : notre objectif n’est pas « tirer le maximum », mais « ne pas se tirer une balle dans le pied par defaut ».
Imaginons que nous avons un modele de domaine (simplifie) :
data class Expense(
val title: String,
val amountCents: Int
)
Et nous construisons un rapport texte sur une liste de depenses.
Mauvaise construction du rapport via +=
fun renderExpensesBad(items: List<Expense>): String {
var s = "Expenses:\n"
for (e in items) {
s += "${e.title}: ${e.amountCents} cents\n"
}
return s
}
Ca fonctionne ? Oui. Ca cree beaucoup de chaines temporaires ? Oui aussi.
Bonne construction du rapport via StringBuilder
fun renderExpensesGood(items: List<Expense>): String {
val sb = StringBuilder()
sb.append("Expenses:\n")
for (e in items) {
sb.append(e.title)
.append(": ")
.append(e.amountCents)
.append(" cents\n")
}
return sb.toString()
}
C’est toujours lisible, mais cela cree moins d’objets temporaires. En plus, on a supprime une interpolation de chaine inutile dans la boucle.
Tableaux vs collections dans les rapports : choisir la structure en connaissance de cause
Parfois, des etudiants essaient « d’accelerer tout » en passant aux tableaux. Mais les tableaux ont une taille fixe, et les operations d’ajout/suppression y conduisent a la creation d’un nouveau tableau et a la copie des elements (c’est montre explicitement dans la documentation Kotlin comme une voie inefficace en cas de changements frequents).
C’est pourquoi, dans notre projet, le conteneur de base pour les enregistrements est une collection (MutableList, List). Et c’est correct. En revanche, si un tampon numerique specialise apparait (par exemple, IntArray pour des sommes par categories), alors on peut utiliser ponctuellement un tableau de primitifs pour eviter les surcouts lies au boxing.
8. Erreurs typiques
Erreur n°1 : construire de grandes chaines via += dans une boucle et s’etonner que « sur de grosses donnees » ca devienne triste.
Le probleme n’est pas que l’operateur + soit mauvais. Le probleme, c’est que la chaine est immuable et qu’a chaque +=, vous creez souvent une nouvelle chaine dans le heap, en laissant l’ancienne au GC. Sur de petites donnees, c’est invisible ; sur de grosses, cela devient une fabrique a dechets.
Erreur n°2 : utiliser les exceptions comme mecanisme ordinaire de branchement (« si ca n’a pas marche — on attrape »).
Une exception est un objet avec etat, y compris la stack trace, necessaire pour le diagnostic et le debogage. La documentation montre que la stack trace reflete la chaine d’appels, et que cette information n’est pas gratuite. Si l’erreur est attendue (par exemple, parsing d’entree), il vaut mieux renvoyer null/Result/un resultat sealed et traiter calmement, en reservant les exceptions aux situations vraiment exceptionnelles.
Erreur n°3 : ne pas comprendre ou se produit le boxing, et stocker d’enormes tableaux numeriques dans Array<Int> / List<Int> alors qu’il faut un tampon dense.
Kotlin avertit explicitement : utiliser Array avec des primitifs entraine du boxing, et pour ces cas, il existe des tableaux de primitifs (IntArray, DoubleArray, etc.). Cela ne veut pas dire qu’il faut reecrire tout le projet en tableaux ; cela veut dire que, pour « un million de nombres », il faut au moins y reflechir.
Erreur n°4 : faire de la reflection dans un point chaud et repeter la recherche de membres a chaque iteration de boucle.
La reflection est un outil qui reduit les garanties du compilateur et ajoute des surcouts. Si vous etes oblige de l’utiliser pour du dynamique, essayez de limiter sa portee et de mettre en cache les resultats de recherche, pour ne pas rescanner les metadonnees encore et encore.
Erreur n°5 : tenter d’optimiser la memoire a l’aveugle, en cassant la lisibilite la ou ce n’est pas necessaire.
La situation la plus frustrante, c’est quand le code devient trois fois plus complexe, mais n’accelere que de 0.5 % sur une zone qui n’etait meme pas un goulot. Il est bien plus utile d’abord de supprimer les fabriques a dechets evidentes (concatenation de chaines dans une boucle, collections intermediaires inutiles, exceptions dans un scenario de masse), puis seulement ensuite de penser aux aspects plus fins.
GO TO FULL VERSION